feat: IME bringing up; clipboard syncing

This commit is contained in:
Miuzarte
2026-04-21 00:36:08 +08:00
parent 47d140a5c7
commit d031839a3a
23 changed files with 1289 additions and 419 deletions

View File

@@ -23,6 +23,7 @@
## Features ## Features
- 控制时可拉起本机输入法,且支持输入中文
- 低延迟音频链路 (默认未启用) - 低延迟音频链路 (默认未启用)
- 受控设备播放 `USAGE_MEDIA` 流时 ([namidaco/namida](https://github.com/namidaco/namida)) ,两设备的音频延迟只差半拍 (没有具体测量能力) - 受控设备播放 `USAGE_MEDIA` 流时 ([namidaco/namida](https://github.com/namidaco/namida)) ,两设备的音频延迟只差半拍 (没有具体测量能力)
- 受控设备播放 `USAGE_GAME` 流时 (明日方舟 Bilibili 服) ,仍存在 100~200ms 的有感延迟 - 受控设备播放 `USAGE_GAME` 流时 (明日方舟 Bilibili 服) ,仍存在 100~200ms 的有感延迟
@@ -37,6 +38,9 @@
- 因为没有设备用于 (也懒得) 测试,应用可能无法正常运行在安卓版本较低的设备上,特别是画中画功能,非常取决于国产 ROM 的实现 - 因为没有设备用于 (也懒得) 测试,应用可能无法正常运行在安卓版本较低的设备上,特别是画中画功能,非常取决于国产 ROM 的实现
- 关闭画中画后不会停止 scrcpy 串流,仍然需要回到应用中点击停止 - 关闭画中画后不会停止 scrcpy 串流,仍然需要回到应用中点击停止
- 跨设备输入中文
- 实现方式为利用剪贴板同步,会导致受控机剪贴板历史被填充输入历史
- 不知道为什么有时候会上屏失败
- 虚拟按键的截图实现方式为发送 - 虚拟按键的截图实现方式为发送
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为 `keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启 `System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
@@ -50,6 +54,16 @@
- 密码锁屏无法捕获: [LSPosed/DisableFlagSecure](https://github.com/LSPosed/DisableFlagSecure) - 密码锁屏无法捕获: [LSPosed/DisableFlagSecure](https://github.com/LSPosed/DisableFlagSecure)
- 开机自动启用 adb: [gist/906291](https://gist.github.com/Miuzarte/9062915f1615d5eebd363c759fda496c) - 开机自动启用 adb: [gist/906291](https://gist.github.com/Miuzarte/9062915f1615d5eebd363c759fda496c)
## NOT-TODO
应该有东西的但我不记得要写什么了
## FAQ
1. 虚拟屏不显示输入法 / 输入法显示在主屏幕
-`--display-ime-policy` 设置为 `local`
- 自行在悬浮球中拉起本机输入法
## 构建 ## 构建
- JDK 17+ - JDK 17+

View File

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

View File

@@ -54,8 +54,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid" applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 14 versionCode = 15
versionName = "0.1.8" versionName = "0.1.9"
externalNativeBuild { externalNativeBuild {
cmake { cmake {

View File

@@ -52,12 +52,12 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordEntry
import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
import io.github.miuzarte.scrcpyforandroid.password.PasswordSanitizer import io.github.miuzarte.scrcpyforandroid.password.PasswordSanitizer
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob

View File

@@ -3,46 +3,31 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.BlendModeColorFilter
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@@ -55,6 +40,7 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SectionSmallTitle
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks
@@ -73,12 +59,13 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.AppListBottomSheet
import io.github.miuzarte.scrcpyforandroid.widgets.AppListEntry
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
@@ -99,17 +86,10 @@ import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.PopupPositionProvider import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SpinnerColors
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
import top.yukonga.miuix.kmp.basic.SpinnerEntry
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.blur.layerBackdrop import top.yukonga.miuix.kmp.blur.layerBackdrop
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Check
import top.yukonga.miuix.kmp.icon.extended.Store
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
import top.yukonga.miuix.kmp.overlay.OverlayListPopup import top.yukonga.miuix.kmp.overlay.OverlayListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
@@ -293,11 +273,14 @@ fun DeviceTabPage(
var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) } var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) }
val sessionInfo by scrcpy.currentSessionState.collectAsState() val sessionInfo by scrcpy.currentSessionState.collectAsState()
val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState() val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
val listingsRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState()
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) } var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
var adbConnecting by rememberSaveable { mutableStateOf(false) } var adbConnecting by rememberSaveable { mutableStateOf(false) }
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) } var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
var showRecentTasksSheet by rememberSaveable { mutableStateOf(false) } var showRecentTasksSheet by rememberSaveable { mutableStateOf(false) }
var showAllAppsSheet by rememberSaveable { mutableStateOf(false) }
var imeRequestToken by rememberSaveable { mutableIntStateOf(0) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val isPreviewCardVisible by remember(listState) { val isPreviewCardVisible by remember(listState) {
derivedStateOf { derivedStateOf {
@@ -341,9 +324,8 @@ fun DeviceTabPage(
adbSession.connectedScrcpyProfileId adbSession.connectedScrcpyProfileId
val connectedScrcpyBundle = resolveScrcpyBundle(connectedScrcpyProfileId) val connectedScrcpyBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
val audioForwardingSupported = adbSession.audioForwardingSupported val apps = remember(listingsRefreshVersion) { scrcpy.listings.apps }
val cameraMirroringSupported = adbSession.cameraMirroringSupported val recentTasks = remember(listingsRefreshVersion) { scrcpy.listings.recentTasks }
val recentTasks = scrcpy.listings.recentTasks
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() } val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
@@ -353,6 +335,68 @@ fun DeviceTabPage(
) )
} }
suspend fun refreshApps() {
runCatching {
scrcpy.listings.getApps(forceRefresh = true)
}.onFailure { error ->
logEvent("获取应用列表失败: ${error.message}", Log.WARN, error)
withContext(Dispatchers.Main) {
snackbar.show("获取应用列表失败")
}
}
}
suspend fun refreshRecentTasks() {
runCatching {
scrcpy.listings.getRecentTasks(forceRefresh = true)
}.onFailure { error ->
logEvent("获取最近任务失败: ${error.message}", Log.WARN, error)
withContext(Dispatchers.Main) {
snackbar.show("获取最近任务失败")
}
}
}
suspend fun pasteLocalClipboard() {
val text =
io.github.miuzarte.scrcpyforandroid.services.LocalInputService.getClipboardText(context)
?.takeIf { it.isNotBlank() }
if (text == null) {
snackbar.show("本机剪贴板为空或不是文本")
return
}
val useLegacyPaste = connectedScrcpyBundle.legacyPaste
runCatching {
withContext(Dispatchers.IO) {
if (useLegacyPaste) {
scrcpy.injectText(text)
} else {
scrcpy.setClipboard(text, paste = true)
}
}
logEvent(
if (useLegacyPaste) "已使用 legacy paste 注入本机剪贴板文本"
else "已同步本机剪贴板到设备并触发粘贴"
)
}.onFailure { error ->
logEvent("本机剪贴板粘贴失败: ${error.message}", Log.WARN, error)
snackbar.show(
if (useLegacyPaste) "legacy 粘贴失败"
else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste"
)
}
}
suspend fun commitImeText(text: String) {
submitImeText(scrcpy, text) { error, useClipboardPaste ->
logEvent("输入法文本提交失败: ${error.message}", Log.WARN, error)
snackbar.show(
if (useClipboardPaste) "非 ASCII 文本粘贴失败"
else "文本输入失败"
)
}
}
var quickConnectInputTemp by rememberSaveable(qdBundle.quickConnectInput) { var quickConnectInputTemp by rememberSaveable(qdBundle.quickConnectInput) {
mutableStateOf(qdBundle.quickConnectInput) mutableStateOf(qdBundle.quickConnectInput)
} }
@@ -1011,21 +1055,26 @@ fun DeviceTabPage(
busy = busy, busy = busy,
activeProfileId = connectedScrcpyProfileId, activeProfileId = connectedScrcpyProfileId,
activeBundle = connectedScrcpyBundle, activeBundle = connectedScrcpyBundle,
audioForwardingSupported = audioForwardingSupported, hideSimpleConfigItems = asBundle.hideSimpleConfigItems,
cameraMirroringSupported = cameraMirroringSupported, audioForwardingSupported = adbSession.audioForwardingSupported,
cameraMirroringSupported = adbSession.cameraMirroringSupported,
adbConnecting = adbConnecting, adbConnecting = adbConnecting,
isQuickConnected = isQuickConnected, isQuickConnected = isQuickConnected,
recentTasksEndAction = { allAppsEndActionText = when {
Text( listingsRefreshBusy -> "..."
text = when { apps.isNotEmpty() -> apps.size.toString()
listingsRefreshBusy -> "..." else -> ""
recentTasks.isNotEmpty() -> recentTasks.size.toString() },
else -> "" onOpenAllApps = {
}, showAllAppsSheet = true
color = colorScheme.onSurfaceVariantActions, if (apps.isEmpty() && !listingsRefreshBusy) {
fontSize = textStyles.body2.fontSize, scope.launch(Dispatchers.IO) { refreshApps() }
modifier = Modifier.padding(end = UiSpacing.ContentVertical), }
) },
recentTasksEndActionText = when {
listingsRefreshBusy -> "..."
recentTasks.isNotEmpty() -> recentTasks.size.toString()
else -> ""
}, },
onOpenRecentTasks = { onOpenRecentTasks = {
showRecentTasksSheet = true showRecentTasksSheet = true
@@ -1078,6 +1127,29 @@ fun DeviceTabPage(
onOpenFullscreen = { onOpenFullscreen = {
context.startActivity(StreamActivity.createIntent(context)) context.startActivity(StreamActivity.createIntent(context))
}, },
imeRequestToken = imeRequestToken,
onImeCommitText = { text ->
commitImeText(text)
},
onImeDeleteSurroundingText = { beforeLength, _ ->
withContext(Dispatchers.IO) {
repeat(beforeLength.coerceAtLeast(1)) {
scrcpy.injectKeycode(0, android.view.KeyEvent.KEYCODE_DEL)
scrcpy.injectKeycode(1, android.view.KeyEvent.KEYCODE_DEL)
}
}
},
onImeKeyEvent = { event ->
withContext(Dispatchers.IO) {
scrcpy.injectKeycode(
action = event.action,
keycode = event.keyCode,
repeat = event.repeatCount,
metaState = event.metaState,
)
}
true
},
autoBringIntoView = pendingScrollToPreview, autoBringIntoView = pendingScrollToPreview,
onAutoBringIntoViewConsumed = { pendingScrollToPreview = false }, onAutoBringIntoViewConsumed = { pendingScrollToPreview = false },
) )
@@ -1090,14 +1162,37 @@ fun DeviceTabPage(
moreActions = virtualButtonLayout.second, moreActions = virtualButtonLayout.second,
showText = asBundle.previewVirtualButtonShowText, showText = asBundle.previewVirtualButtonShowText,
onAction = { action -> onAction = { action ->
if (action == VirtualButtonAction.PASSWORD_INPUT) { when (action) {
snackbar.show("当前页面无法拉起验证") VirtualButtonAction.RECENT_TASKS -> {
return@VirtualButtonCard showRecentTasksSheet = true
} if (recentTasks.isEmpty() && !listingsRefreshBusy) {
val keycode = action.keycode ?: return@VirtualButtonCard scope.launch(Dispatchers.IO) { refreshApps() }
runBusy("发送 ${action.title}") { scope.launch(Dispatchers.IO) { refreshRecentTasks() }
scrcpy.injectKeycode(0, keycode) }
scrcpy.injectKeycode(1, keycode) }
VirtualButtonAction.ALL_APPS -> {
showAllAppsSheet = true
if (apps.isEmpty() && !listingsRefreshBusy) {
scope.launch(Dispatchers.IO) { refreshApps() }
}
}
VirtualButtonAction.TOGGLE_IME -> {
imeRequestToken++
}
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> {
scope.launch { pasteLocalClipboard() }
}
else -> {
val keycode = action.keycode ?: return@VirtualButtonCard
runBusy("发送 ${action.title}") {
scrcpy.injectKeycode(0, keycode)
scrcpy.injectKeycode(1, keycode)
}
}
} }
}, },
passwordPopupContent = passwordPopupContent =
@@ -1131,10 +1226,43 @@ fun DeviceTabPage(
} }
RecentTasksBottomSheet( AppListBottomSheet(
show = showRecentTasksSheet, show = showRecentTasksSheet,
tasks = recentTasks, title = "最近任务",
scrcpy = scrcpy, loadingText = "最近任务加载中",
emptyText = "没有可用的最近任务",
entries = recentTasks.map { task ->
val app = scrcpy.listings.findCachedApp(task.packageName)
AppListEntry(
key = task.packageName,
title = app?.label?.takeIf { it.isNotBlank() } ?: task.packageName,
summary = if (app?.label != null) task.packageName else null,
system = app?.system,
onClick = {
showRecentTasksSheet = false
if (sessionInfo == null) runBusy("启动 scrcpy") {
startScrcpySession(startAppOverride = task.packageName)
}
else runBusy("启动应用") {
runCatching {
scrcpy.startApp(task.packageName)
}.onSuccess {
logEvent("已在当前显示启动应用: ${task.packageName}")
}.onFailure { error ->
snackbar.show("通过 scrcpy 控制通道启动应用失败,回退 ADB")
logEvent(
"通过 scrcpy 控制通道启动应用失败,回退 ADB" +
": ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
Log.WARN,
error,
)
adbCoordinator.startApp(packageName = task.packageName)
logEvent("已通过 ADB 启动应用: ${task.packageName}")
}
}
},
)
},
refreshBusy = listingsRefreshBusy, refreshBusy = listingsRefreshBusy,
onDismissRequest = { showRecentTasksSheet = false }, onDismissRequest = { showRecentTasksSheet = false },
onRefresh = { onRefresh = {
@@ -1149,33 +1277,52 @@ fun DeviceTabPage(
} }
} }
}, },
onLaunchTask = { task ->
showRecentTasksSheet = false
if (sessionInfo == null) {
runBusy("启动 scrcpy") {
startScrcpySession(startAppOverride = task.packageName)
}
} else {
runBusy("启动应用") {
runCatching {
scrcpy.startApp(task.packageName)
}.onSuccess {
logEvent("已在当前显示启动应用: ${task.packageName}")
}.onFailure { error ->
snackbar.show("通过 scrcpy 控制通道启动应用失败,回退 ADB")
logEvent(
"通过 scrcpy 控制通道启动应用失败,回退 ADB" +
": ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
Log.WARN,
error,
)
adbCoordinator.startApp(packageName = task.packageName)
logEvent("已通过 ADB 启动应用: ${task.packageName}")
}
}
}
},
) )
AppListBottomSheet(
show = showAllAppsSheet,
title = "所有应用",
loadingText = "应用列表加载中",
emptyText = "没有可用的应用列表",
entries = apps.map { app ->
AppListEntry(
key = app.packageName,
title = app.label?.takeIf { it.isNotBlank() } ?: app.packageName,
summary = if (app.label != null) app.packageName else null,
system = app.system,
onClick = {
showAllAppsSheet = false
if (sessionInfo == null) {
runBusy("启动 scrcpy") {
startScrcpySession(startAppOverride = app.packageName)
}
} else {
runBusy("启动应用") {
runCatching {
scrcpy.startApp(app.packageName)
}.onSuccess {
logEvent("已在当前显示启动应用: ${app.packageName}")
}.onFailure { error ->
snackbar.show("通过 scrcpy 控制通道启动应用失败,回退 ADB")
logEvent(
"通过 scrcpy 控制通道启动应用失败,回退 ADB" +
": ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
Log.WARN,
error,
)
adbCoordinator.startApp(packageName = app.packageName)
logEvent("已通过 ADB 启动应用: ${app.packageName}")
}
}
}
},
)
},
refreshBusy = listingsRefreshBusy,
onDismissRequest = { showAllAppsSheet = false },
onRefresh = { scope.launch(Dispatchers.IO) { refreshApps() } },
)
} }
@Composable @Composable
@@ -1217,158 +1364,6 @@ private fun DeviceMenuPopup(
} }
} }
@Composable
private fun RecentTasksBottomSheet(
show: Boolean,
tasks: List<Scrcpy.RecentTaskInfo>,
scrcpy: Scrcpy,
refreshBusy: Boolean,
onDismissRequest: () -> Unit,
onRefresh: () -> Unit,
onLaunchTask: (Scrcpy.RecentTaskInfo) -> Unit,
) {
val spinnerColors = SpinnerDefaults.spinnerColors()
OverlayBottomSheet(
show = show,
title = "最近任务",
defaultWindowInsetsPadding = false,
onDismissRequest = onDismissRequest,
endAction = {
IconButton(
onClick = {
if (!refreshBusy) {
onRefresh()
}
},
) {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = "刷新最近任务",
)
}
},
) {
Column(
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
when {
tasks.isEmpty() && refreshBusy -> {
Text(
text = "最近任务加载中",
modifier = Modifier.padding(horizontal = UiSpacing.Large),
)
}
tasks.isEmpty() -> {
Text(
text = "没有可用的最近任务",
modifier = Modifier.padding(horizontal = UiSpacing.Large),
)
}
else -> {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 420.dp),
) {
items(tasks.size) { index ->
val task = tasks[index]
val app = scrcpy.listings.findCachedApp(task.packageName)
val entry = SpinnerEntry(
icon = app?.system?.let { system ->
{ modifier ->
Icon(
imageVector =
if (system) Icons.Rounded.Android // MiuixIcons.Tune
else MiuixIcons.Store,
contentDescription = task.packageName,
modifier = modifier,
)
}
},
title = app?.label?.takeIf { it.isNotBlank() } ?: task.packageName,
summary = app?.let { task.packageName },
)
RecentTaskSheetItem(
entry = entry,
entryCount = tasks.size,
index = index,
spinnerColors = spinnerColors,
onClick = { onLaunchTask(task) },
)
}
}
}
}
Spacer(Modifier.height(UiSpacing.SheetBottom))
}
}
}
@Composable
private fun RecentTaskSheetItem(
entry: SpinnerEntry,
entryCount: Int,
index: Int,
spinnerColors: SpinnerColors,
onClick: () -> Unit,
) {
val additionalTopPadding = if (index == 0) 20.dp else 12.dp
val additionalBottomPadding = if (index == entryCount - 1) 20.dp else 12.dp
val checkColorFilter = remember {
BlendModeColorFilter(Color.Transparent, BlendMode.SrcIn)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.drawBehind { drawRect(spinnerColors.containerColor) }
.clickable(onClick = onClick)
.padding(horizontal = 20.dp)
.padding(top = additionalTopPadding, bottom = additionalBottomPadding),
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
entry.icon?.invoke(
Modifier
.sizeIn(minWidth = 26.dp, minHeight = 26.dp)
.padding(end = 12.dp)
)
Column {
entry.title?.let {
Text(
text = it,
fontSize = textStyles.body1.fontSize,
fontWeight = FontWeight.Medium,
color = spinnerColors.contentColor,
)
}
entry.summary?.let {
Text(
text = it,
fontSize = textStyles.body2.fontSize,
color = spinnerColors.summaryColor,
)
}
}
}
Image(
imageVector = MiuixIcons.Basic.Check,
contentDescription = null,
modifier = Modifier
.padding(start = 12.dp)
.sizeIn(minWidth = 20.dp, minHeight = 20.dp),
colorFilter = checkColorFilter,
)
}
}
@Composable @Composable
private fun DeviceMenuPopupItem( private fun DeviceMenuPopupItem(
text: String, text: String,

View File

@@ -3,6 +3,7 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.Rect import android.graphics.Rect
import android.util.Log import android.util.Log
import android.view.KeyEvent
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -52,6 +53,8 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.AppListBottomSheet
import io.github.miuzarte.scrcpyforandroid.widgets.AppListEntry
import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
@@ -83,6 +86,8 @@ fun FullscreenControlScreen(
val snackbar = LocalSnackbarController.current val snackbar = LocalSnackbarController.current
val currentSession by scrcpy.currentSessionState.collectAsState() val currentSession by scrcpy.currentSessionState.collectAsState()
val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
val listingsRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState()
val asBundleShared by appSettings.bundleState.collectAsState() val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared) val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
@@ -125,8 +130,13 @@ fun FullscreenControlScreen(
moreActions = buttonItems.second, moreActions = buttonItems.second,
) )
} }
val recentTasks = remember(listingsRefreshVersion) { scrcpy.listings.recentTasks }
val apps = remember(listingsRefreshVersion) { scrcpy.listings.apps }
var currentFps by remember { mutableFloatStateOf(0f) } var currentFps by remember { mutableFloatStateOf(0f) }
var showRecentTasksSheet by rememberSaveable { mutableStateOf(false) }
var showAllAppsSheet by rememberSaveable { mutableStateOf(false) }
var imeRequestToken by rememberSaveable { mutableIntStateOf(0) }
DisposableEffect(activity) { DisposableEffect(activity) {
val window = activity?.window val window = activity?.window
@@ -178,6 +188,66 @@ fun FullscreenControlScreen(
} }
} }
suspend fun refreshApps() {
runCatching {
withContext(Dispatchers.IO) {
scrcpy.listings.getApps(forceRefresh = true)
}
}.onFailure { error ->
snackbar.show("获取应用列表失败")
Log.w("FullscreenControlPage", "refreshApps failed", error)
}
}
suspend fun refreshRecentTasks() {
runCatching {
withContext(Dispatchers.IO) {
scrcpy.listings.getRecentTasks(forceRefresh = true)
}
}.onFailure { error ->
snackbar.show("获取最近任务失败")
Log.w("FullscreenControlPage", "refreshRecentTasks failed", error)
}
}
suspend fun pasteLocalClipboard() {
val session = currentSession ?: return
val text = io.github.miuzarte.scrcpyforandroid.services.LocalInputService.getClipboardText(
activity ?: return
)
?.takeIf { it.isNotBlank() }
if (text == null) {
snackbar.show("本机剪贴板为空或不是文本")
return
}
val useLegacyPaste = session.legacyPaste
runCatching {
withContext(Dispatchers.IO) {
if (useLegacyPaste) {
scrcpy.injectText(text)
} else {
scrcpy.setClipboard(text, paste = true)
}
}
}.onFailure { error ->
Log.w("FullscreenControlPage", "pasteLocalClipboard failed", error)
snackbar.show(
if (useLegacyPaste) "legacy 粘贴失败"
else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste"
)
}
}
suspend fun commitImeText(text: String) {
submitImeText(scrcpy, text) { error, useClipboardPaste ->
Log.w("FullscreenControlPage", "commitImeText failed", error)
snackbar.show(
if (useClipboardPaste) "非 ASCII 文本粘贴失败"
else "文本输入失败"
)
}
}
Scaffold( Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbar.hostState) }, snackbarHost = { SnackbarHost(snackbar.hostState) },
@@ -189,13 +259,16 @@ fun FullscreenControlScreen(
) { ) {
val session = currentSession ?: return@Box val session = currentSession ?: return@Box
FullscreenControlPage( FullscreenControlPage(
scrcpy = scrcpy,
session = session, session = session,
onDismiss = onBack, onDismiss = onBack,
showDebugInfo = fullscreenDebugInfo && !isInPip, showDebugInfo = fullscreenDebugInfo && !isInPip,
currentFps = currentFps, currentFps = currentFps,
imeRequestToken = imeRequestToken,
enableBackHandler = false, enableBackHandler = false,
interactive = !isInPip, interactive = !isInPip,
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged, onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
onImeCommitText = ::commitImeText,
onInjectTouch = { action, pointerId, x, y, pressure, buttons -> onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
scrcpy.injectTouch( scrcpy.injectTouch(
@@ -217,10 +290,33 @@ fun FullscreenControlScreen(
bar.Fullscreen( bar.Fullscreen(
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
onAction = { action -> onAction = { action ->
if (action == VirtualButtonAction.PASSWORD_INPUT) { when (action) {
snackbar.show("当前页面无法拉起验证") VirtualButtonAction.RECENT_TASKS -> {
} else { showRecentTasksSheet = true
action.keycode?.let { sendKeycode(it) } if (recentTasks.isEmpty() && !listingsRefreshBusy) {
refreshApps()
refreshRecentTasks()
}
}
VirtualButtonAction.ALL_APPS -> {
showAllAppsSheet = true
if (apps.isEmpty() && !listingsRefreshBusy) {
refreshApps()
}
}
VirtualButtonAction.TOGGLE_IME -> {
imeRequestToken++
}
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> {
pasteLocalClipboard()
}
else -> {
action.keycode?.let { sendKeycode(it) }
}
} }
}, },
passwordPopupContent = if (fragmentActivity == null) { passwordPopupContent = if (fragmentActivity == null) {
@@ -243,10 +339,33 @@ fun FullscreenControlScreen(
actions = floatingActions, actions = floatingActions,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onAction = { action -> onAction = { action ->
if (action == VirtualButtonAction.PASSWORD_INPUT) { when (action) {
snackbar.show("当前页面无法拉起验证") VirtualButtonAction.RECENT_TASKS -> {
} else { showRecentTasksSheet = true
action.keycode?.let { sendKeycode(it) } if (recentTasks.isEmpty() && !listingsRefreshBusy) {
refreshApps()
refreshRecentTasks()
}
}
VirtualButtonAction.ALL_APPS -> {
showAllAppsSheet = true
if (apps.isEmpty() && !listingsRefreshBusy) {
refreshApps()
}
}
VirtualButtonAction.TOGGLE_IME -> {
imeRequestToken++
}
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> {
pasteLocalClipboard()
}
else -> {
action.keycode?.let { sendKeycode(it) }
}
} }
}, },
passwordPopupContent = if (fragmentActivity == null) { passwordPopupContent = if (fragmentActivity == null) {
@@ -263,6 +382,77 @@ fun FullscreenControlScreen(
}, },
) )
} }
AppListBottomSheet(
show = showRecentTasksSheet,
title = "最近任务",
loadingText = "最近任务加载中",
emptyText = "没有可用的最近任务",
entries = recentTasks.map { task ->
val app = scrcpy.listings.findCachedApp(task.packageName)
AppListEntry(
key = task.packageName,
title = app?.label?.takeIf { it.isNotBlank() } ?: task.packageName,
summary = if (app?.label != null) task.packageName else null,
system = app?.system,
onClick = {
showRecentTasksSheet = false
taskScope.launch {
runCatching {
withContext(Dispatchers.IO) {
scrcpy.startApp(task.packageName)
}
}.onFailure { error ->
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
}
}
},
)
},
refreshBusy = listingsRefreshBusy,
onDismissRequest = { showRecentTasksSheet = false },
onRefresh = {
taskScope.launch(Dispatchers.Main) {
refreshApps()
refreshRecentTasks()
}
},
)
AppListBottomSheet(
show = showAllAppsSheet,
title = "所有应用",
loadingText = "应用列表加载中",
emptyText = "没有可用的应用列表",
entries = apps.map { app ->
AppListEntry(
key = app.packageName,
title = app.label?.takeIf { it.isNotBlank() } ?: app.packageName,
summary = if (app.label != null) app.packageName else null,
system = app.system,
onClick = {
showAllAppsSheet = false
taskScope.launch {
runCatching {
withContext(Dispatchers.IO) {
scrcpy.startApp(app.packageName)
}
}.onFailure { error ->
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
}
}
},
)
},
refreshBusy = listingsRefreshBusy,
onDismissRequest = { showAllAppsSheet = false },
onRefresh = {
taskScope.launch(Dispatchers.Main) {
refreshApps()
}
},
)
} }
} }
} }
@@ -285,13 +475,16 @@ fun FullscreenControlScreen(
*/ */
@Composable @Composable
fun FullscreenControlPage( fun FullscreenControlPage(
scrcpy: Scrcpy,
session: Scrcpy.Session.SessionInfo, session: Scrcpy.Session.SessionInfo,
onDismiss: () -> Unit, onDismiss: () -> Unit,
showDebugInfo: Boolean, showDebugInfo: Boolean,
currentFps: Float, currentFps: Float,
imeRequestToken: Int = 0,
enableBackHandler: Boolean = true, enableBackHandler: Boolean = true,
interactive: Boolean = true, interactive: Boolean = true,
onVideoBoundsInWindowChanged: (Rect?) -> Unit = {}, onVideoBoundsInWindowChanged: (Rect?) -> Unit = {},
onImeCommitText: suspend (String) -> Unit,
onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit, onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
) { ) {
BackHandler(enabled = enableBackHandler, onBack = onDismiss) BackHandler(enabled = enableBackHandler, onBack = onDismiss)
@@ -372,6 +565,27 @@ fun FullscreenControlPage(
) )
}, },
session = session, session = session,
imeRequestToken = imeRequestToken,
onImeCommitText = onImeCommitText,
onImeDeleteSurroundingText = { beforeLength, _ ->
withContext(Dispatchers.IO) {
repeat(beforeLength.coerceAtLeast(1)) {
scrcpy.injectKeycode(0, KeyEvent.KEYCODE_DEL)
scrcpy.injectKeycode(1, KeyEvent.KEYCODE_DEL)
}
}
},
onImeKeyEvent = { event ->
withContext(Dispatchers.IO) {
scrcpy.injectKeycode(
action = event.action,
keycode = event.keyCode,
repeat = event.repeatCount,
metaState = event.metaState,
)
}
true
},
) )
} }

View File

@@ -0,0 +1,27 @@
package io.github.miuzarte.scrcpyforandroid.pages
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal suspend fun submitImeText(
scrcpy: Scrcpy,
text: String,
onFailure: suspend (error: Throwable, useClipboardPaste: Boolean) -> Unit,
) {
if (text.isBlank()) {
return
}
val useClipboardPaste = text.any { it.code > 0x7F }
runCatching {
withContext(Dispatchers.IO) {
if (useClipboardPaste) {
scrcpy.setClipboard(text, paste = true)
} else {
scrcpy.injectText(text)
}
}
}.onFailure { error ->
onFailure(error, useClipboardPaste)
}
}

View File

@@ -9,16 +9,15 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -31,7 +30,6 @@ fun ReorderDevicesScreen(
show: Boolean, show: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val qdBundleShared by quickDevices.bundleState.collectAsState() val qdBundleShared by quickDevices.bundleState.collectAsState()

View File

@@ -47,6 +47,7 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSpinner import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSpinner
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
@@ -69,7 +70,6 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -583,14 +583,11 @@ internal fun ScrcpyAllOptionsPage(
soBundle.cameraSizeCustom, soBundle.cameraSizeCustom,
soBundle.cameraSizeUseCustom, soBundle.cameraSizeUseCustom,
) { ) {
if (cameraSizes.isNotEmpty()) { when {
null cameraSizes.isNotEmpty() -> null
} else if (soBundle.cameraSizeUseCustom && soBundle.cameraSizeCustom.isNotBlank()) { soBundle.cameraSizeUseCustom && soBundle.cameraSizeCustom.isNotBlank() -> soBundle.cameraSizeCustom
soBundle.cameraSizeCustom soBundle.cameraSize.isNotBlank() -> soBundle.cameraSize
} else if (soBundle.cameraSize.isNotBlank()) { else -> null
soBundle.cameraSize
} else {
null
} }
} }
@@ -1583,7 +1580,7 @@ internal fun ScrcpyAllOptionsPage(
}, },
) )
SwitchPreference( SwitchPreference(
title = "禁用结束后清理", title = "禁用 MediaCodec 出错时自动降级",
summary = "--no-downsize-on-error", summary = "--no-downsize-on-error",
checked = !soBundle.downsizeOnError, checked = !soBundle.downsizeOnError,
onCheckedChange = { onCheckedChange = {
@@ -1597,6 +1594,26 @@ internal fun ScrcpyAllOptionsPage(
) )
}, },
) )
SwitchPreference(
title = "粘贴兼容回退仅支持ASCII/英文字符)",
summary = "--legacy-paste",
checked = soBundle.legacyPaste,
onCheckedChange = {
soBundle = soBundle.copy(
legacyPaste = it
)
},
)
SwitchPreference(
title = "禁用剪贴板双向同步",
summary = "--no-clipboard-autosync",
checked = !soBundle.clipboardAutosync,
onCheckedChange = {
soBundle = soBundle.copy(
clipboardAutosync = !it
)
},
)
SwitchPreference( SwitchPreference(
title = "禁用结束后清理", title = "禁用结束后清理",
summary = "--no-cleanup", summary = "--no-cleanup",

View File

@@ -34,6 +34,7 @@ import io.github.miuzarte.scrcpyforandroid.LockscreenPasswordActivity
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SectionSmallTitle
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
@@ -47,7 +48,6 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -286,6 +286,14 @@ fun SettingsPage(
asBundle = asBundle.copy(fullscreenDebugInfo = it) asBundle = asBundle.copy(fullscreenDebugInfo = it)
}, },
) )
SwitchPreference(
title = "设备页隐藏简单设置项",
summary = "启用后设备页仅保留更多参数、所有应用、最近任务和启动/停止按钮",
checked = asBundle.hideSimpleConfigItems,
onCheckedChange = {
asBundle = asBundle.copy(hideSimpleConfigItems = it)
},
)
SuperSlider( SuperSlider(
title = "预览卡高度", title = "预览卡高度",
summary = "设备页预览卡高度", summary = "设备页预览卡高度",
@@ -343,6 +351,16 @@ fun SettingsPage(
asBundle = asBundle.copy(showFullscreenFloatingButton = it) asBundle = asBundle.copy(showFullscreenFloatingButton = it)
}, },
) )
SwitchPreference(
title = "实时同步剪贴板到受控机",
summary = "本机剪贴板更新后会自动同步到受控机" +
"\n禁用后需要使用虚拟按钮中的粘贴才能粘贴本机内容" +
"\nMIUI 完全不允许后台监听剪贴板,因此该选项在小米设备上可能无效",
checked = asBundle.realtimeClipboardSyncToDevice,
onCheckedChange = {
asBundle = asBundle.copy(realtimeClipboardSyncToDevice = it)
},
)
} }
} }

View File

@@ -21,12 +21,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope

View File

@@ -1,4 +1,4 @@
package io.github.miuzarte.scrcpyforandroid.widgets package io.github.miuzarte.scrcpyforandroid.scaffolds
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement

View File

@@ -1,17 +1,24 @@
package io.github.miuzarte.scrcpyforandroid.widgets package io.github.miuzarte.scrcpyforandroid.scaffolds
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.basic.SmallTitle import top.yukonga.miuix.kmp.basic.SmallTitle
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
@Composable @Composable
fun SectionSmallTitle( fun SectionSmallTitle(
text: String, text: String,
modifier: Modifier = Modifier,
textColor: Color = colorScheme.onBackgroundVariant,
insideMargin: PaddingValues = PaddingValues(16.dp, 8.dp), insideMargin: PaddingValues = PaddingValues(16.dp, 8.dp),
) { ) {
SmallTitle( SmallTitle(
text = text, text = text,
modifier = modifier,
textColor = textColor,
insideMargin = insideMargin, insideMargin = insideMargin,
) )
} }

View File

@@ -153,13 +153,13 @@ data class ClientOptions(
var disableScreensaver: Boolean = false, var disableScreensaver: Boolean = false,
// var forwardKeyRepeat: Boolean, // var forwardKeyRepeat: Boolean,
// var legacyPaste: Boolean, var legacyPaste: Boolean = false,
// --power-off-on-close // --power-off-on-close
var powerOffOnClose: Boolean = false, // to server var powerOffOnClose: Boolean = false, // to server
// --no-clipboard-autosync // --no-clipboard-autosync
// var clipboardAutosync: Boolean = true, // to server var clipboardAutosync: Boolean = true, // to server
// --no-downsize-on-error // --no-downsize-on-error
var downsizeOnError: Boolean = true, // to server var downsizeOnError: Boolean = true, // to server
@@ -555,6 +555,8 @@ data class ClientOptions(
cameraFps = cameraFps, cameraFps = cameraFps,
powerOffOnClose = powerOffOnClose, powerOffOnClose = powerOffOnClose,
legacyPaste = legacyPaste,
clipboardAutosync = clipboardAutosync,
downsizeOnError = downsizeOnError, downsizeOnError = downsizeOnError,
@@ -569,4 +571,4 @@ data class ClientOptions(
list = list, list = list,
) )
} }
} }

View File

@@ -1,5 +1,6 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy package io.github.miuzarte.scrcpyforandroid.scrcpy
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
@@ -15,11 +16,16 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.EncoderType import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.EncoderType
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -61,7 +67,24 @@ class Scrcpy(
private val lowLatency: Boolean = false, private val lowLatency: Boolean = false,
) { ) {
private val session = Session() private val session = Session(::handleRemoteClipboardText)
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val clipboardSyncLock = Any()
private val clipboardManager by lazy {
appContext.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
}
@Volatile
private var clipboardListenerRegistered = false
@Volatile
private var lastKnownLocalClipboardText: String? = null
@Volatile
private var pendingRemoteClipboardText: String? = null
private val clipboardChangedListener = ClipboardManager.OnPrimaryClipChangedListener {
handleLocalClipboardChanged()
}
private val _currentSessionState = MutableStateFlow<Session.SessionInfo?>(null) private val _currentSessionState = MutableStateFlow<Session.SessionInfo?>(null)
val currentSessionState: StateFlow<Session.SessionInfo?> = _currentSessionState.asStateFlow() val currentSessionState: StateFlow<Session.SessionInfo?> = _currentSessionState.asStateFlow()
@@ -143,8 +166,9 @@ class Scrcpy(
} }
// Create session info // Create session info
_currentSessionState.value = info _currentSessionState.value = info.copy(legacyPaste = options.legacyPaste)
isRunning = true isRunning = true
startClipboardSync()
// Setup video consumer (notify NativeCoreFacade to setup decoders) // Setup video consumer (notify NativeCoreFacade to setup decoders)
if (options.video) { if (options.video) {
@@ -204,6 +228,7 @@ class Scrcpy(
audioPlayer = null audioPlayer = null
isRunning = false isRunning = false
_currentSessionState.value = null _currentSessionState.value = null
stopClipboardSync()
Log.i(TAG, "stop(): Session stopped successfully") Log.i(TAG, "stop(): Session stopped successfully")
true true
} catch (e: Exception) { } catch (e: Exception) {
@@ -214,8 +239,6 @@ class Scrcpy(
fun isStarted(): Boolean = isRunning && session.isStarted() fun isStarted(): Boolean = isRunning && session.isStarted()
fun getCurrentSession(): Session.SessionInfo? = currentSessionState.value
suspend fun startApp(name: String) = session.startApp(name) suspend fun startApp(name: String) = session.startApp(name)
suspend fun injectKeycode( suspend fun injectKeycode(
@@ -232,6 +255,8 @@ class Scrcpy(
suspend fun injectText(text: String) = session.injectText(text) suspend fun injectText(text: String) = session.injectText(text)
suspend fun setClipboard(text: String, paste: Boolean) = session.setClipboard(text, paste)
suspend fun injectTouch( suspend fun injectTouch(
action: Int, action: Int,
pointerId: Long, pointerId: Long,
@@ -278,34 +303,69 @@ class Scrcpy(
_currentSessionState.value = current.copy(width = width, height = height) _currentSessionState.value = current.copy(width = width, height = height)
} }
sealed class ListResult { private fun startClipboardSync() {
data class Encoders( synchronized(clipboardSyncLock) {
val videoEncoders: List<String>, lastKnownLocalClipboardText = LocalInputService.getClipboardText(appContext)
val audioEncoders: List<String>, pendingRemoteClipboardText = null
val videoEncoderTypes: Map<String, String> = emptyMap(), if (!clipboardListenerRegistered) {
val audioEncoderTypes: Map<String, String> = emptyMap(), clipboardManager?.addPrimaryClipChangedListener(clipboardChangedListener)
val rawOutput: String = "", clipboardListenerRegistered = true
) : ListResult() }
}
}
data class Displays( private fun stopClipboardSync() {
val displays: List<DisplayInfo>, synchronized(clipboardSyncLock) {
val rawOutput: String = "", if (clipboardListenerRegistered) {
) : ListResult() clipboardManager?.removePrimaryClipChangedListener(clipboardChangedListener)
clipboardListenerRegistered = false
}
pendingRemoteClipboardText = null
}
}
data class Cameras( private fun handleLocalClipboardChanged() {
val cameras: List<CameraInfo>, val text = LocalInputService.getClipboardText(appContext)
val rawOutput: String = "", val shouldSync = synchronized(clipboardSyncLock) {
) : ListResult() if (text == lastKnownLocalClipboardText) {
return@synchronized false
}
lastKnownLocalClipboardText = text
if (text != null && text == pendingRemoteClipboardText) {
pendingRemoteClipboardText = null
return@synchronized false
}
true
}
if (!shouldSync) {
return
}
if (!isRunning || !appSettings.bundleState.value.realtimeClipboardSyncToDevice) {
return
}
val content = text?.takeIf { it.isNotBlank() } ?: return
backgroundScope.launch {
runCatching {
session.setClipboard(content, paste = false)
}.onFailure { error ->
Log.w(TAG, "realtime clipboard sync failed", error)
}
}
}
data class CameraSizes( private fun handleRemoteClipboardText(text: String) {
val sizes: List<String>, val shouldWrite = synchronized(clipboardSyncLock) {
val rawOutput: String = "", if (text == lastKnownLocalClipboardText) {
) : ListResult() return@synchronized false
}
data class Apps( pendingRemoteClipboardText = text
val apps: List<AppInfo>, lastKnownLocalClipboardText = text
val rawOutput: String = "", true
) : ListResult() }
if (!shouldWrite) {
return
}
LocalInputService.setClipboardText(appContext, text)
} }
inner class Listings { inner class Listings {
@@ -740,7 +800,9 @@ class Scrcpy(
* Session manager for scrcpy protocol. * Session manager for scrcpy protocol.
* Handles socket communication, video/audio streaming, and control input. * Handles socket communication, video/audio streaming, and control input.
*/ */
class Session { class Session(
private val onRemoteClipboardText: (String) -> Unit,
) {
private val mutex = Mutex() private val mutex = Mutex()
@Volatile @Volatile
@@ -758,6 +820,9 @@ class Scrcpy(
@Volatile @Volatile
private var audioReaderThread: Thread? = null private var audioReaderThread: Thread? = null
@Volatile
private var controlReaderThread: Thread? = null
private val serverLogBuffer = ArrayDeque<String>() private val serverLogBuffer = ArrayDeque<String>()
suspend fun start( suspend fun start(
@@ -881,6 +946,10 @@ class Scrcpy(
val controlWriter = controlStream?.let { stream -> val controlWriter = controlStream?.let { stream ->
ControlWriter(DataOutputStream(stream.outputStream)) ControlWriter(DataOutputStream(stream.outputStream))
} }
val controlInput = controlStream?.let { stream ->
if (stream === firstStream) firstInput
else DataInputStream(BufferedInputStream(stream.inputStream))
}
val newSession = ActiveSession( val newSession = ActiveSession(
info = sessionInfo, info = sessionInfo,
@@ -892,9 +961,13 @@ class Scrcpy(
audioStream = audioStream, audioStream = audioStream,
audioInput = audioInput, audioInput = audioInput,
controlStream = controlStream, controlStream = controlStream,
controlInput = controlInput,
controlWriter = controlWriter, controlWriter = controlWriter,
) )
activeSession = newSession activeSession = newSession
if (options.control && options.clipboardAutosync)
startControlReaderThread(newSession)
return sessionInfo return sessionInfo
} catch (t: Throwable) { } catch (t: Throwable) {
val tail = snapshotServerLogs() val tail = snapshotServerLogs()
@@ -1031,6 +1104,14 @@ class Scrcpy(
} }
} }
suspend fun setClipboard(text: String, paste: Boolean) = mutex.withLock {
try {
requireControlWriter().setClipboard(text, paste)
} catch (e: IllegalStateException) {
Log.w(TAG, "setClipboard(): control channel not available", e)
}
}
suspend fun injectTouch( suspend fun injectTouch(
action: Int, action: Int,
pointerId: Long, pointerId: Long,
@@ -1121,6 +1202,12 @@ class Scrcpy(
} }
audioReaderThread = null audioReaderThread = null
if (Thread.currentThread() !== controlReaderThread) {
runCatching { controlReaderThread?.interrupt() }
runCatching { controlReaderThread?.join(300) }
}
controlReaderThread = null
runCatching { session.controlStream?.close() } runCatching { session.controlStream?.close() }
runCatching { session.audioStream?.close() } runCatching { session.audioStream?.close() }
runCatching { session.videoStream?.close() } runCatching { session.videoStream?.close() }
@@ -1140,6 +1227,57 @@ class Scrcpy(
?: throw IllegalStateException("scrcpy control channel not available") ?: throw IllegalStateException("scrcpy control channel not available")
} }
private fun startControlReaderThread(session: ActiveSession) {
val controlInput = session.controlInput ?: return
val controlStream = session.controlStream ?: return
if (controlReaderThread?.isAlive == true) return
controlReaderThread = thread(start = true, name = "scrcpy-control-reader") {
try {
while (activeSession === session && !controlStream.closed) {
try {
when (controlInput.readUnsignedByte()) {
DEVICE_MSG_TYPE_CLIPBOARD -> {
val size = controlInput.readInt()
if (size !in 0..DEVICE_MSG_MAX_SIZE) {
throw IllegalStateException("Invalid clipboard size: $size")
}
val payload = ByteArray(size)
controlInput.readFully(payload)
onRemoteClipboardText(payload.toString(Charsets.UTF_8))
}
DEVICE_MSG_TYPE_ACK_CLIPBOARD -> {
controlInput.readLong()
}
DEVICE_MSG_TYPE_UHID_OUTPUT -> {
controlInput.readUnsignedShort()
val size = controlInput.readUnsignedShort()
controlInput.skipBytes(size)
}
else -> {
throw IllegalStateException("Unknown device message type")
}
}
} catch (_: EOFException) {
break
} catch (_: InterruptedException) {
if (activeSession !== session || controlStream.closed) {
break
}
Thread.interrupted()
} catch (e: Exception) {
Log.w(TAG, "control reader failed", e)
break
}
}
} finally {
}
}
}
private fun startServerLogThread( private fun startServerLogThread(
serverStream: AdbSocketStream, serverStream: AdbSocketStream,
socketName: String socketName: String
@@ -1223,6 +1361,7 @@ class Scrcpy(
val height: Int, val height: Int,
val audioCodecId: Int = 0, val audioCodecId: Int = 0,
val controlEnabled: Boolean, val controlEnabled: Boolean,
val legacyPaste: Boolean = false,
val host: String = "", val host: String = "",
val port: Int = Defaults.ADB_PORT, val port: Int = Defaults.ADB_PORT,
) )
@@ -1286,6 +1425,7 @@ class Scrcpy(
val audioStream: AdbSocketStream?, val audioStream: AdbSocketStream?,
val audioInput: DataInputStream?, val audioInput: DataInputStream?,
val controlStream: AdbSocketStream?, val controlStream: AdbSocketStream?,
val controlInput: DataInputStream?,
val controlWriter: ControlWriter?, val controlWriter: ControlWriter?,
) )
@@ -1309,6 +1449,17 @@ class Scrcpy(
output.flush() output.flush()
} }
@Synchronized
fun setClipboard(text: String, paste: Boolean) {
val bytes = text.toByteArray(Charsets.UTF_8)
output.writeByte(TYPE_SET_CLIPBOARD)
output.writeLong(CLIPBOARD_SEQUENCE_INVALID)
output.writeByte(if (paste) 1 else 0)
output.writeInt(bytes.size)
output.write(bytes)
output.flush()
}
@Synchronized @Synchronized
fun injectTouch( fun injectTouch(
action: Int, action: Int,
@@ -1415,14 +1566,20 @@ class Scrcpy(
private const val AUDIO_DISABLED = 0 private const val AUDIO_DISABLED = 0
private const val AUDIO_ERROR = 1 private const val AUDIO_ERROR = 1
private const val DEVICE_MSG_MAX_SIZE = 1 shl 18
private const val DEVICE_MSG_TYPE_CLIPBOARD = 0
private const val DEVICE_MSG_TYPE_ACK_CLIPBOARD = 1
private const val DEVICE_MSG_TYPE_UHID_OUTPUT = 2
private const val TYPE_INJECT_KEYCODE = 0 private const val TYPE_INJECT_KEYCODE = 0
private const val TYPE_INJECT_TEXT = 1 private const val TYPE_INJECT_TEXT = 1
private const val TYPE_INJECT_TOUCH_EVENT = 2 private const val TYPE_INJECT_TOUCH_EVENT = 2
private const val TYPE_INJECT_SCROLL_EVENT = 3 private const val TYPE_INJECT_SCROLL_EVENT = 3
private const val TYPE_BACK_OR_SCREEN_ON = 4 private const val TYPE_BACK_OR_SCREEN_ON = 4
private const val TYPE_SET_CLIPBOARD = 9
private const val TYPE_SET_DISPLAY_POWER = 10 private const val TYPE_SET_DISPLAY_POWER = 10
private const val TYPE_START_APP = 16 private const val TYPE_START_APP = 16
private const val CLIPBOARD_SEQUENCE_INVALID = 0L
private fun socketNameFor(scid: Int): String { private fun socketNameFor(scid: Int): String {
return "scrcpy_%08x".format(scid) return "scrcpy_%08x".format(scid)

View File

@@ -72,6 +72,8 @@ data class ServerParams(
// var forceAdbForward: Boolean, // var forceAdbForward: Boolean,
var powerOffOnClose: Boolean, var powerOffOnClose: Boolean,
var legacyPaste: Boolean,
var clipboardAutosync: Boolean,
var downsizeOnError: Boolean, var downsizeOnError: Boolean,
// var tcpip: Boolean, // var tcpip: Boolean,
@@ -236,9 +238,10 @@ data class ServerParams(
if (powerOffOnClose) { if (powerOffOnClose) {
cmd.add("power_off_on_close=true") cmd.add("power_off_on_close=true")
} }
// not implemented if (legacyPaste) {
val clipBoardAutosync = false cmd.add("legacy_paste=true")
if (!clipBoardAutosync) { }
if (!clipboardAutosync) {
cmd.add("clipboard_autosync=false") cmd.add("clipboard_autosync=false")
} }
if (!downsizeOnError) { if (!downsizeOnError) {

View File

@@ -0,0 +1,45 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import android.view.View
import android.view.inputmethod.InputMethodManager
object LocalInputService {
fun showSoftKeyboard(view: View) {
view.requestFocus()
val inputManager =
view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
?: return
inputManager.showSoftInput(view, 0)
}
fun getClipboardText(context: Context): String? {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
?: return null
if (!clipboard.hasPrimaryClip()) {
return null
}
val clip = clipboard.primaryClip ?: return null
if (clip.itemCount <= 0) {
return null
}
val description = clipboard.primaryClipDescription
if (description != null &&
!description.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) &&
!description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML) &&
!description.hasMimeType(ClipDescription.MIMETYPE_TEXT_URILIST)
) {
return null
}
return clip.getItemAt(0).coerceToText(context)?.toString()
}
fun setClipboardText(context: Context, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
?: return
clipboard.setPrimaryClip(ClipData.newPlainText("scrcpy", text))
}
}

View File

@@ -62,6 +62,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
floatPreferencesKey("fullscreen_floating_button_y_fraction"), floatPreferencesKey("fullscreen_floating_button_y_fraction"),
0.72f 0.72f
) )
val HIDE_SIMPLE_CONFIG_ITEMS = Pair(
booleanPreferencesKey("hide_simple_config_items"),
false,
)
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
intPreferencesKey("device_preview_card_height_dp"), intPreferencesKey("device_preview_card_height_dp"),
1080 / 3 1080 / 3
@@ -72,7 +76,21 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
) )
val VIRTUAL_BUTTONS_LAYOUT = Pair( val VIRTUAL_BUTTONS_LAYOUT = Pair(
stringPreferencesKey("virtual_buttons_layout"), stringPreferencesKey("virtual_buttons_layout"),
"more:1,password_input:0,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0" "more:1" +
",app_switch:1,home:0,back:1" +
",password_input:0" +
",all_apps:0" +
",recent_tasks:0" +
",toggle_ime:0" +
",paste_local_clipboard:0" +
",menu:0,notification:0" +
",volume_up:0,volume_down:0,volume_mute:0" +
",power:0,screenshot:0" +
""
) )
val CUSTOM_SERVER_URI = Pair( val CUSTOM_SERVER_URI = Pair(
stringPreferencesKey("custom_server_uri"), stringPreferencesKey("custom_server_uri"),
@@ -111,6 +129,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
booleanPreferencesKey("password_require_auth"), booleanPreferencesKey("password_require_auth"),
true true
) )
val REALTIME_CLIPBOARD_SYNC_TO_DEVICE = Pair(
booleanPreferencesKey("realtime_clipboard_sync_to_device"),
true
)
val LAST_UPDATE_CHECK_AT = Pair( val LAST_UPDATE_CHECK_AT = Pair(
longPreferencesKey("last_update_check_at"), longPreferencesKey("last_update_check_at"),
0L 0L
@@ -132,6 +154,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val showFullscreenFloatingButton by setting(SHOW_FULLSCREEN_FLOATING_BUTTON) val showFullscreenFloatingButton by setting(SHOW_FULLSCREEN_FLOATING_BUTTON)
val fullscreenFloatingButtonXFraction by setting(FULLSCREEN_FLOATING_BUTTON_X_FRACTION) val fullscreenFloatingButtonXFraction by setting(FULLSCREEN_FLOATING_BUTTON_X_FRACTION)
val fullscreenFloatingButtonYFraction by setting(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION) val fullscreenFloatingButtonYFraction by setting(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION)
val hideSimpleConfigItems by setting(HIDE_SIMPLE_CONFIG_ITEMS)
val devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP) val devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP)
val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT)
val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT) val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT)
@@ -148,6 +171,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY) val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT)
val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH) val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH)
val realtimeClipboardSyncToDevice by setting(REALTIME_CLIPBOARD_SYNC_TO_DEVICE)
val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT) val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT)
@Parcelize @Parcelize
@@ -164,6 +188,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val showFullscreenFloatingButton: Boolean, val showFullscreenFloatingButton: Boolean,
val fullscreenFloatingButtonXFraction: Float, val fullscreenFloatingButtonXFraction: Float,
val fullscreenFloatingButtonYFraction: Float, val fullscreenFloatingButtonYFraction: Float,
val hideSimpleConfigItems: Boolean,
val devicePreviewCardHeightDp: Int, val devicePreviewCardHeightDp: Int,
val previewVirtualButtonShowText: Boolean, val previewVirtualButtonShowText: Boolean,
val virtualButtonsLayout: String, val virtualButtonsLayout: String,
@@ -176,6 +201,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbMdnsLanDiscovery: Boolean, val adbMdnsLanDiscovery: Boolean,
val adbAutoLoadAppListOnConnect: Boolean, val adbAutoLoadAppListOnConnect: Boolean,
val passwordRequireAuth: Boolean, val passwordRequireAuth: Boolean,
val realtimeClipboardSyncToDevice: Boolean,
val lastUpdateCheckAt: Long, val lastUpdateCheckAt: Long,
) : Parcelable { ) : Parcelable {
} }
@@ -193,6 +219,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
bundleField(SHOW_FULLSCREEN_FLOATING_BUTTON) { bundle: Bundle -> bundle.showFullscreenFloatingButton }, bundleField(SHOW_FULLSCREEN_FLOATING_BUTTON) { bundle: Bundle -> bundle.showFullscreenFloatingButton },
bundleField(FULLSCREEN_FLOATING_BUTTON_X_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonXFraction }, bundleField(FULLSCREEN_FLOATING_BUTTON_X_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonXFraction },
bundleField(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonYFraction }, bundleField(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonYFraction },
bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { bundle: Bundle -> bundle.hideSimpleConfigItems },
bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp }, bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp },
bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText }, bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout }, bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
@@ -205,6 +232,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery }, bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect }, bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect },
bundleField(PASSWORD_REQUIRE_AUTH) { bundle: Bundle -> bundle.passwordRequireAuth }, bundleField(PASSWORD_REQUIRE_AUTH) { bundle: Bundle -> bundle.passwordRequireAuth },
bundleField(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) { bundle: Bundle -> bundle.realtimeClipboardSyncToDevice },
bundleField(LAST_UPDATE_CHECK_AT) { bundle: Bundle -> bundle.lastUpdateCheckAt }, bundleField(LAST_UPDATE_CHECK_AT) { bundle: Bundle -> bundle.lastUpdateCheckAt },
) )
@@ -223,6 +251,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
showFullscreenFloatingButton = preferences.read(SHOW_FULLSCREEN_FLOATING_BUTTON), showFullscreenFloatingButton = preferences.read(SHOW_FULLSCREEN_FLOATING_BUTTON),
fullscreenFloatingButtonXFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_X_FRACTION), fullscreenFloatingButtonXFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_X_FRACTION),
fullscreenFloatingButtonYFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION), fullscreenFloatingButtonYFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION),
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP), devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT), previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT), virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),
@@ -237,6 +266,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY), adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT), adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT),
passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH), passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH),
realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE),
lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT), lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT),
) )

View File

@@ -188,6 +188,14 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
booleanPreferencesKey("power_off_on_close"), booleanPreferencesKey("power_off_on_close"),
false, false,
) )
val LEGACY_PASTE = Pair(
booleanPreferencesKey("legacy_paste"),
false,
)
val CLIPBOARD_AUTOSYNC = Pair(
booleanPreferencesKey("clipboard_autosync"),
true,
)
val DOWNSIZE_ON_ERROR = Pair( val DOWNSIZE_ON_ERROR = Pair(
booleanPreferencesKey("downsize_on_error"), booleanPreferencesKey("downsize_on_error"),
true, true,
@@ -294,6 +302,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
stayAwake = STAY_AWAKE.defaultValue, stayAwake = STAY_AWAKE.defaultValue,
disableScreensaver = DISABLE_SCREENSAVER.defaultValue, disableScreensaver = DISABLE_SCREENSAVER.defaultValue,
powerOffOnClose = POWER_OFF_ON_CLOSE.defaultValue, powerOffOnClose = POWER_OFF_ON_CLOSE.defaultValue,
legacyPaste = LEGACY_PASTE.defaultValue,
clipboardAutosync = CLIPBOARD_AUTOSYNC.defaultValue,
downsizeOnError = DOWNSIZE_ON_ERROR.defaultValue, downsizeOnError = DOWNSIZE_ON_ERROR.defaultValue,
cleanup = CLEANUP.defaultValue, cleanup = CLEANUP.defaultValue,
powerOn = POWER_ON.defaultValue, powerOn = POWER_ON.defaultValue,
@@ -353,6 +363,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val stayAwake by setting(STAY_AWAKE) val stayAwake by setting(STAY_AWAKE)
val disableScreensaver by setting(DISABLE_SCREENSAVER) val disableScreensaver by setting(DISABLE_SCREENSAVER)
val powerOffOnClose by setting(POWER_OFF_ON_CLOSE) val powerOffOnClose by setting(POWER_OFF_ON_CLOSE)
val legacyPaste by setting(LEGACY_PASTE)
val clipboardAutosync by setting(CLIPBOARD_AUTOSYNC)
val cleanup by setting(CLEANUP) val cleanup by setting(CLEANUP)
val powerOn by setting(POWER_ON) val powerOn by setting(POWER_ON)
val video by setting(VIDEO) val video by setting(VIDEO)
@@ -409,6 +421,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val stayAwake: Boolean, val stayAwake: Boolean,
val disableScreensaver: Boolean, val disableScreensaver: Boolean,
val powerOffOnClose: Boolean, val powerOffOnClose: Boolean,
val legacyPaste: Boolean,
val clipboardAutosync: Boolean,
val downsizeOnError: Boolean, val downsizeOnError: Boolean,
val cleanup: Boolean, val cleanup: Boolean,
val powerOn: Boolean, val powerOn: Boolean,
@@ -469,6 +483,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake }, bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake },
bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver }, bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver },
bundleField(POWER_OFF_ON_CLOSE) { bundle: Bundle -> bundle.powerOffOnClose }, bundleField(POWER_OFF_ON_CLOSE) { bundle: Bundle -> bundle.powerOffOnClose },
bundleField(LEGACY_PASTE) { bundle: Bundle -> bundle.legacyPaste },
bundleField(CLIPBOARD_AUTOSYNC) { bundle: Bundle -> bundle.clipboardAutosync },
bundleField(CLEANUP) { bundle: Bundle -> bundle.cleanup }, bundleField(CLEANUP) { bundle: Bundle -> bundle.cleanup },
bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn }, bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn },
bundleField(VIDEO) { bundle: Bundle -> bundle.video }, bundleField(VIDEO) { bundle: Bundle -> bundle.video },
@@ -529,6 +545,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
stayAwake = preferences.read(STAY_AWAKE), stayAwake = preferences.read(STAY_AWAKE),
disableScreensaver = preferences.read(DISABLE_SCREENSAVER), disableScreensaver = preferences.read(DISABLE_SCREENSAVER),
powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE), powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE),
legacyPaste = preferences.read(LEGACY_PASTE),
clipboardAutosync = preferences.read(CLIPBOARD_AUTOSYNC),
downsizeOnError = preferences.read(DOWNSIZE_ON_ERROR), downsizeOnError = preferences.read(DOWNSIZE_ON_ERROR),
cleanup = preferences.read(CLEANUP), cleanup = preferences.read(CLEANUP),
powerOn = preferences.read(POWER_ON), powerOn = preferences.read(POWER_ON),
@@ -604,6 +622,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
stayAwake = bundle.stayAwake, stayAwake = bundle.stayAwake,
disableScreensaver = bundle.disableScreensaver, disableScreensaver = bundle.disableScreensaver,
powerOffOnClose = bundle.powerOffOnClose, powerOffOnClose = bundle.powerOffOnClose,
legacyPaste = bundle.legacyPaste,
clipboardAutosync = bundle.clipboardAutosync,
downsizeOnError = bundle.downsizeOnError, downsizeOnError = bundle.downsizeOnError,
cleanup = bundle.cleanup, cleanup = bundle.cleanup,
powerOn = bundle.powerOn, powerOn = bundle.powerOn,

View File

@@ -0,0 +1,151 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.SpinnerColors
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Store
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
data class AppListEntry(
val key: String,
val title: String,
val summary: String? = null,
val system: Boolean? = null,
val onClick: () -> Unit,
)
@Composable
fun AppListBottomSheet(
show: Boolean,
title: String,
loadingText: String,
emptyText: String,
entries: List<AppListEntry>,
refreshBusy: Boolean,
onDismissRequest: () -> Unit,
onRefresh: () -> Unit,
) {
val spinnerColors = SpinnerDefaults.spinnerColors()
OverlayBottomSheet(
show = show,
title = title,
defaultWindowInsetsPadding = false,
onDismissRequest = onDismissRequest,
endAction = {
IconButton(
onClick = { if (!refreshBusy) onRefresh() },
) {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = "刷新$title",
)
}
},
) {
when {
entries.isEmpty() && refreshBusy -> {
Text(
text = loadingText,
modifier = Modifier
.fillMaxWidth()
.padding(UiSpacing.Large),
textAlign = TextAlign.Center,
)
}
entries.isEmpty() -> {
Text(
text = emptyText,
modifier = Modifier
.fillMaxWidth()
.padding(UiSpacing.Large),
textAlign = TextAlign.Center,
)
}
else -> {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(2f / 3f),
) {
items(items = entries, key = { it.key }) { entry ->
AppListBottomSheetItem(
entry = entry,
spinnerColors = spinnerColors,
)
}
}
}
}
}
}
@Composable
private fun AppListBottomSheetItem(
entry: AppListEntry,
spinnerColors: SpinnerColors,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.background(spinnerColors.containerColor)
.clickable(onClick = entry.onClick)
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector =
if (entry.system == true) Icons.Rounded.Android
else MiuixIcons.Store,
contentDescription = entry.title.ifBlank { entry.summary ?: "" },
modifier = Modifier
.sizeIn(minWidth = 26.dp, minHeight = 26.dp)
.padding(end = 12.dp),
)
Column {
Text(
text = entry.title,
fontSize = textStyles.body1.fontSize,
color = spinnerColors.contentColor,
)
entry.summary?.let {
Text(
text = it,
fontSize = textStyles.body2.fontSize,
color = spinnerColors.summaryColor,
)
}
}
}
}
}

View File

@@ -1,9 +1,9 @@
package io.github.miuzarte.scrcpyforandroid.widgets package io.github.miuzarte.scrcpyforandroid.widgets
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.KeyEvent
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -86,6 +85,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Button import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.ButtonDefaults
@@ -104,6 +105,7 @@ import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
import top.yukonga.miuix.kmp.preference.SwitchPreference import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
import top.yukonga.miuix.kmp.utils.PressFeedbackType import top.yukonga.miuix.kmp.utils.PressFeedbackType
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -253,6 +255,10 @@ internal fun PreviewCard(
sessionInfo: Scrcpy.Session.SessionInfo?, sessionInfo: Scrcpy.Session.SessionInfo?,
previewHeightDp: Int, previewHeightDp: Int,
onOpenFullscreen: () -> Unit, onOpenFullscreen: () -> Unit,
imeRequestToken: Int = 0,
onImeCommitText: (suspend (String) -> Unit)? = null,
onImeDeleteSurroundingText: (suspend (beforeLength: Int, afterLength: Int) -> Unit)? = null,
onImeKeyEvent: (suspend (KeyEvent) -> Boolean)? = null,
autoBringIntoView: Boolean = false, autoBringIntoView: Boolean = false,
onAutoBringIntoViewConsumed: () -> Unit = {}, onAutoBringIntoViewConsumed: () -> Unit = {},
) { ) {
@@ -335,6 +341,10 @@ internal fun PreviewCard(
ScrcpyVideoSurface( ScrcpyVideoSurface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
session = sessionInfo, session = sessionInfo,
imeRequestToken = imeRequestToken,
onImeCommitText = onImeCommitText,
onImeDeleteSurroundingText = onImeDeleteSurroundingText,
onImeKeyEvent = onImeKeyEvent,
) )
} }
} }
@@ -401,14 +411,17 @@ internal fun ConfigPanel(
busy: Boolean, busy: Boolean,
activeProfileId: String, activeProfileId: String,
activeBundle: ScrcpyOptions.Bundle, activeBundle: ScrcpyOptions.Bundle,
hideSimpleConfigItems: Boolean,
audioForwardingSupported: Boolean, audioForwardingSupported: Boolean,
cameraMirroringSupported: Boolean, cameraMirroringSupported: Boolean,
adbConnecting: Boolean, adbConnecting: Boolean,
isQuickConnected: Boolean, isQuickConnected: Boolean,
recentTasksEndAction: @Composable (RowScope.() -> Unit)? = null, allAppsEndActionText: String,
onOpenRecentTasks: (() -> Unit)? = null, onOpenAllApps: () -> Unit,
recentTasksEndActionText: String,
onOpenRecentTasks: () -> Unit,
onOpenAdvanced: () -> Unit, onOpenAdvanced: () -> Unit,
onStartStopHaptic: (() -> Unit)? = null, onStartStopHaptic: () -> Unit,
onStart: () -> Unit, onStart: () -> Unit,
onStop: () -> Unit, onStop: () -> Unit,
sessionInfo: Scrcpy.Session.SessionInfo?, sessionInfo: Scrcpy.Session.SessionInfo?,
@@ -470,149 +483,167 @@ internal fun ConfigPanel(
} }
Card { Card {
SwitchPreference( if (!hideSimpleConfigItems) {
title = "音频转发", SwitchPreference(
summary = "转发设备音频到本机 (Android 11+)", title = "音频转发",
checked = soBundle.audio, summary = "转发设备音频到本机 (Android 11+)",
onCheckedChange = { soBundle = soBundle.copy(audio = it) }, checked = soBundle.audio,
enabled = !sessionStarted onCheckedChange = { soBundle = soBundle.copy(audio = it) },
&& audioForwardingSupported, enabled = !sessionStarted
) && audioForwardingSupported,
)
OverlayDropdownPreference( OverlayDropdownPreference(
title = "音频编码", title = "音频编码",
summary = "--audio-codec", summary = "--audio-codec",
items = audioCodecItems, items = audioCodecItems,
selectedIndex = audioCodecIndex, selectedIndex = audioCodecIndex,
onSelectedIndexChange = { onSelectedIndexChange = {
val codec = Codec.AUDIO[it] val codec = Codec.AUDIO[it]
soBundle = soBundle.copy(audioCodec = codec.string) soBundle = soBundle.copy(audioCodec = codec.string)
if (codec == Codec.FLAC) { if (codec == Codec.FLAC) {
snackbar.show("注意FLAC编解码会引入较大的延迟") snackbar.show("注意FLAC编解码会引入较大的延迟")
} }
}, },
enabled = !sessionStarted && soBundle.audio, enabled = !sessionStarted && soBundle.audio,
) )
AnimatedVisibility(audioBitRateVisibility) { AnimatedVisibility(audioBitRateVisibility) {
SuperSlider(
title = "音频码率",
summary = "--audio-bit-rate",
value = ScrcpyPresets.AudioBitRate
.indexOfOrNearest(soBundle.audioBitRate / 1000)
.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt()
.coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
soBundle = soBundle.copy(
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000
)
},
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
unit = "Kbps",
zeroStateText = "默认",
displayText = (soBundle.audioBitRate / 1_000).toString(),
inputInitialValue =
if (soBundle.audioBitRate <= 0) ""
else (soBundle.audioBitRate / 1_000).toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { raw ->
raw.toIntOrNull()
?.takeIf { it >= 0 }
?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) }
},
enabled = !sessionStarted,
)
}
OverlayDropdownPreference(
title = "视频编码",
summary = "--video-codec",
items = videoCodecItems,
selectedIndex = videoCodecIndex,
onSelectedIndexChange = {
val codec = Codec.VIDEO[it]
soBundle = soBundle.copy(videoCodec = codec.string)
if (codec == Codec.AV1) {
snackbar.show("注意绝大部分设备不支持AV1硬件编码")
}
},
enabled = !sessionStarted,
)
@SuppressLint("DefaultLocale")
SuperSlider( SuperSlider(
title = "频码率", title = "频码率",
summary = "--audio-bit-rate", summary = "--video-bit-rate",
value = ScrcpyPresets.AudioBitRate value = soBundle.videoBitRate / 1_000_000f,
.indexOfOrNearest(soBundle.audioBitRate / 1000) onValueChange = { mbps ->
.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt()
.coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
soBundle = soBundle.copy( soBundle = soBundle.copy(
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10)
) )
}, },
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), valueRange = 0f..40f,
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), steps = 400 - 1,
unit = "Kbps", unit = "Mbps",
zeroStateText = "默认", zeroStateText = "默认",
displayText = (soBundle.audioBitRate / 1_000).toString(), displayFormatter = { String.format("%.1f", it) },
inputInitialValue = inputInitialValue =
if (soBundle.audioBitRate <= 0) "" if (soBundle.videoBitRate <= 0) ""
else (soBundle.audioBitRate / 1_000).toString(), else String.format("%.1f", soBundle.videoBitRate / 1_000_000f),
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { text ->
inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), var dotUsed = false
text.filter { ch ->
when {
ch.isDigit() -> true
ch == '.' && !dotUsed -> {
dotUsed = true
true
}
else -> false
}
}
},
inputValueRange = 0f..UInt.MAX_VALUE.toFloat(),
onInputConfirm = { raw -> onInputConfirm = { raw ->
raw.toIntOrNull() raw.toFloatOrNull()?.let { parsed ->
?.takeIf { it >= 0 } if (parsed >= 0f) {
?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) } soBundle = soBundle.copy(
videoBitRate = (parsed * 1_000_000f).roundToInt()
)
}
}
}, },
enabled = !sessionStarted, enabled = !sessionStarted,
) )
} }
OverlayDropdownPreference(
title = "视频编码",
summary = "--video-codec",
items = videoCodecItems,
selectedIndex = videoCodecIndex,
onSelectedIndexChange = {
val codec = Codec.VIDEO[it]
soBundle = soBundle.copy(videoCodec = codec.string)
if (codec == Codec.AV1) {
snackbar.show("注意绝大部分设备不支持AV1硬件编码")
}
},
enabled = !sessionStarted,
)
@SuppressLint("DefaultLocale")
SuperSlider(
title = "视频码率",
summary = "--video-bit-rate",
value = soBundle.videoBitRate / 1_000_000f,
onValueChange = { mbps ->
soBundle = soBundle.copy(
videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10)
)
},
valueRange = 0f..40f,
steps = 400 - 1,
unit = "Mbps",
zeroStateText = "默认",
displayFormatter = { String.format("%.1f", it) },
inputInitialValue =
if (soBundle.videoBitRate <= 0) ""
else String.format("%.1f", soBundle.videoBitRate / 1_000_000f),
inputFilter = { text ->
var dotUsed = false
text.filter { ch ->
when {
ch.isDigit() -> true
ch == '.' && !dotUsed -> {
dotUsed = true
true
}
else -> false
}
}
},
inputValueRange = 0f..UInt.MAX_VALUE.toFloat(),
onInputConfirm = { raw ->
raw.toFloatOrNull()?.let { parsed ->
if (parsed >= 0f) {
soBundle = soBundle.copy(
videoBitRate = (parsed * 1_000_000f).roundToInt()
)
}
}
},
enabled = !sessionStarted,
)
ArrowPreference( ArrowPreference(
title = "更多参数", title = if (!hideSimpleConfigItems) "更多参数" else "所有参数",
summary = "所有 scrcpy 参数", summary = "所有 scrcpy 参数",
onClick = onOpenAdvanced, onClick = onOpenAdvanced,
enabled = !sessionStarted, enabled = !sessionStarted,
) )
if (onOpenRecentTasks != null) {
ArrowPreference( ArrowPreference(
title = "最近任务", title = "所有应用",
endActions = { endActions = {
recentTasksEndAction?.invoke(this) Text(
}, text = allAppsEndActionText,
onClick = onOpenRecentTasks, color = colorScheme.onSurfaceVariantActions,
enabled = !busy && !adbConnecting, fontSize = textStyles.body2.fontSize,
) modifier = Modifier.padding(end = UiSpacing.ContentVertical),
} )
},
onClick = onOpenAllApps,
enabled = !busy && !adbConnecting,
)
ArrowPreference(
title = "最近任务",
endActions = {
Text(
text = recentTasksEndActionText,
color = colorScheme.onSurfaceVariantActions,
fontSize = textStyles.body2.fontSize,
modifier = Modifier.padding(end = UiSpacing.ContentVertical),
)
},
onClick = onOpenRecentTasks,
enabled = !busy && !adbConnecting,
)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = UiSpacing.ContentVertical) .padding(all = UiSpacing.ContentVertical),
.padding(bottom = UiSpacing.ContentVertical),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) { ) {
if (isQuickConnected) TextButton( if (isQuickConnected) TextButton(
text = "断开", text = "断开",
onClick = { onClick = {
onStartStopHaptic?.invoke() onStartStopHaptic()
onDisconnect() onDisconnect()
}, },
modifier = Modifier.weight(1f / 4f), modifier = Modifier.weight(1f / 4f),
@@ -621,7 +652,7 @@ internal fun ConfigPanel(
if (!sessionStarted) TextButton( if (!sessionStarted) TextButton(
text = "启动", text = "启动",
onClick = { onClick = {
onStartStopHaptic?.invoke() onStartStopHaptic()
onStart() onStart()
}, },
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
@@ -631,7 +662,7 @@ internal fun ConfigPanel(
if (sessionStarted) TextButton( if (sessionStarted) TextButton(
text = "停止", text = "停止",
onClick = { onClick = {
onStartStopHaptic?.invoke() onStartStopHaptic()
onStop() onStop()
}, },
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
@@ -813,14 +844,19 @@ private fun PairingDialog(
fun ScrcpyVideoSurface( fun ScrcpyVideoSurface(
modifier: Modifier, modifier: Modifier,
session: Scrcpy.Session.SessionInfo?, session: Scrcpy.Session.SessionInfo?,
imeRequestToken: Int = 0,
onImeCommitText: (suspend (String) -> Unit)? = null,
onImeDeleteSurroundingText: (suspend (beforeLength: Int, afterLength: Int) -> Unit)? = null,
onImeKeyEvent: (suspend (KeyEvent) -> Boolean)? = null,
) { ) {
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val imeInputMutex = remember { Mutex() }
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
var currentSurface by remember { mutableStateOf<Surface?>(null) } var currentSurface by remember { mutableStateOf<Surface?>(null) }
var currentSurfaceView by remember { mutableStateOf<SurfaceView?>(null) } var currentSurfaceView by remember { mutableStateOf<ScrcpyInputSurfaceView?>(null) }
val latestSession by rememberUpdatedState(session) val latestSession by rememberUpdatedState(session)
@@ -840,6 +876,13 @@ fun ScrcpyVideoSurface(
} }
} }
LaunchedEffect(imeRequestToken, currentSurfaceView) {
if (imeRequestToken == 0) return@LaunchedEffect
val surfaceView = currentSurfaceView ?: return@LaunchedEffect
surfaceView.setCommitTextEnabled(true)
io.github.miuzarte.scrcpyforandroid.services.LocalInputService.showSoftKeyboard(surfaceView)
}
DisposableEffect(lifecycleOwner, session, currentSurface) { DisposableEffect(lifecycleOwner, session, currentSurface) {
val surface = currentSurface val surface = currentSurface
if ( if (
@@ -882,8 +925,42 @@ fun ScrcpyVideoSurface(
AndroidView( AndroidView(
modifier = modifier, modifier = modifier,
factory = { context -> factory = { context ->
SurfaceView(context).apply { ScrcpyInputSurfaceView(context).apply {
currentSurfaceView = this currentSurfaceView = this
inputCallbacks = object : ScrcpyInputSurfaceView.InputCallbacks {
override fun handleKeyEvent(event: KeyEvent): Boolean {
val handler = onImeKeyEvent ?: return false
taskScope.launch {
imeInputMutex.withLock {
handler(event)
}
}
return true
}
override fun handleCommitText(text: CharSequence): Boolean {
val handler = onImeCommitText ?: return false
taskScope.launch {
imeInputMutex.withLock {
handler(text.toString())
}
}
return true
}
override fun handleDeleteSurroundingText(
beforeLength: Int,
afterLength: Int
): Boolean {
val handler = onImeDeleteSurroundingText ?: return false
taskScope.launch {
imeInputMutex.withLock {
handler(beforeLength, afterLength)
}
}
return true
}
}
holder.addCallback(object : SurfaceHolder.Callback { holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) { override fun surfaceCreated(holder: SurfaceHolder) {
val newSurface = holder.surface val newSurface = holder.surface

View File

@@ -0,0 +1,66 @@
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.SurfaceView
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
class ScrcpyInputSurfaceView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : SurfaceView(context, attrs, defStyleAttr) {
interface InputCallbacks {
fun handleKeyEvent(event: KeyEvent): Boolean
fun handleCommitText(text: CharSequence): Boolean
fun handleDeleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean
}
var inputCallbacks: InputCallbacks? = null
private var commitTextEnabled = false
fun setCommitTextEnabled(enabled: Boolean) {
commitTextEnabled = enabled
isFocusable = enabled
isFocusableInTouchMode = enabled
if (enabled) requestFocus()
else clearFocus()
}
override fun onCheckIsTextEditor(): Boolean {
return commitTextEnabled || 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 (!commitTextEnabled) 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 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)
}
}
}
}

View File

@@ -21,7 +21,10 @@ import androidx.compose.material.icons.automirrored.rounded.VolumeDown
import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material.icons.automirrored.rounded.VolumeOff
import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.automirrored.rounded.VolumeUp
import androidx.compose.material.icons.rounded.Apps import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.ContentPaste
import androidx.compose.material.icons.rounded.DashboardCustomize
import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Keyboard
import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Notifications
@@ -145,6 +148,30 @@ enum class VirtualButtonAction(
"填充锁屏密码", "填充锁屏密码",
Icons.Rounded.Password, Icons.Rounded.Password,
null null
),
ALL_APPS(
"all_apps",
"所有应用",
Icons.Rounded.Apps,
null
),
RECENT_TASKS(
"recent_tasks",
"最近任务",
Icons.Rounded.DashboardCustomize,
null
),
TOGGLE_IME(
"toggle_ime",
"拉起输入法",
Icons.Rounded.Keyboard,
null
),
PASTE_LOCAL_CLIPBOARD(
"paste_local_clipboard",
"粘贴本机剪贴板",
Icons.Rounded.ContentPaste,
null
); );
} }