From d031839a3a1437c35953b083c155b1bf32e0a0ce Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Tue, 21 Apr 2026 00:36:08 +0800 Subject: [PATCH] feat: IME bringing up; clipboard syncing --- README.md | 14 + TODO.md | 2 + app/build.gradle.kts | 4 +- .../LockscreenPasswordActivity.kt | 2 +- .../scrcpyforandroid/pages/DeviceTabScreen.kt | 453 +++++++++--------- .../pages/FullscreenControlScreen.kt | 230 ++++++++- .../pages/ImeTextSubmission.kt | 27 ++ .../pages/ReorderDevicesScreen.kt | 4 +- .../pages/ScrcpyAllOptionsScreen.kt | 37 +- .../scrcpyforandroid/pages/SettingsScreen.kt | 20 +- .../pages/VirtualButtonOrderScreen.kt | 2 +- .../{widgets => scaffolds}/ReorderableList.kt | 2 +- .../SectionSmallTitle.kt | 9 +- .../scrcpyforandroid/scrcpy/ClientOptions.kt | 8 +- .../scrcpyforandroid/scrcpy/Scrcpy.kt | 217 +++++++-- .../scrcpyforandroid/scrcpy/ServerParams.kt | 9 +- .../services/LocalInputService.kt | 45 ++ .../scrcpyforandroid/storage/AppSettings.kt | 32 +- .../scrcpyforandroid/storage/ScrcpyOptions.kt | 20 + .../widgets/AppBottomSheets.kt | 151 ++++++ .../scrcpyforandroid/widgets/DeviceWidgets.kt | 327 ++++++++----- .../widgets/ScrcpyInputSurfaceView.kt | 66 +++ .../widgets/VirtualButtons.kt | 27 ++ 23 files changed, 1289 insertions(+), 419 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ImeTextSubmission.kt rename app/src/main/java/io/github/miuzarte/scrcpyforandroid/{widgets => scaffolds}/ReorderableList.kt (99%) rename app/src/main/java/io/github/miuzarte/scrcpyforandroid/{widgets => scaffolds}/SectionSmallTitle.kt (52%) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/LocalInputService.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ScrcpyInputSurfaceView.kt diff --git a/README.md b/README.md index c031901..5ae388f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ ## Features +- 控制时可拉起本机输入法,且支持输入中文 - 低延迟音频链路 (默认未启用) - 受控设备播放 `USAGE_MEDIA` 流时 ([namidaco/namida](https://github.com/namidaco/namida)) ,两设备的音频延迟只差半拍 (没有具体测量能力) - 受控设备播放 `USAGE_GAME` 流时 (明日方舟 Bilibili 服) ,仍存在 100~200ms 的有感延迟 @@ -37,6 +38,9 @@ - 因为没有设备用于 (也懒得) 测试,应用可能无法正常运行在安卓版本较低的设备上,特别是画中画功能,非常取决于国产 ROM 的实现 - 关闭画中画后不会停止 scrcpy 串流,仍然需要回到应用中点击停止 +- 跨设备输入中文 + - 实现方式为利用剪贴板同步,会导致受控机剪贴板历史被填充输入历史 + - 不知道为什么有时候会上屏失败 - 虚拟按键的截图实现方式为发送 `keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为 `System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启 @@ -50,6 +54,16 @@ - 密码锁屏无法捕获: [LSPosed/DisableFlagSecure](https://github.com/LSPosed/DisableFlagSecure) - 开机自动启用 adb: [gist/906291](https://gist.github.com/Miuzarte/9062915f1615d5eebd363c759fda496c) +## NOT-TODO + +应该有东西的但我不记得要写什么了 + +## FAQ + +1. 虚拟屏不显示输入法 / 输入法显示在主屏幕 + - 将 `--display-ime-policy` 设置为 `local` + - 自行在悬浮球中拉起本机输入法 + ## 构建 - JDK 17+ diff --git a/TODO.md b/TODO.md index e8f4a7c..867ad0c 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,8 @@ # TODO +adb shell page (in nav dock) + ## PARAMS ### LOWER PRIORITY diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2c33d2f..7d5b4f1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,8 +54,8 @@ android { applicationId = "io.github.miuzarte.scrcpyforandroid" minSdk = 26 targetSdk = 37 - versionCode = 14 - versionName = "0.1.8" + versionCode = 15 + versionName = "0.1.9" externalNativeBuild { cmake { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt index 6f44189..abe73a7 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/LockscreenPasswordActivity.kt @@ -52,12 +52,12 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordEntry import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository import io.github.miuzarte.scrcpyforandroid.password.PasswordSanitizer 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.SnackbarController import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop -import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index a69bb61..668ec2b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -3,46 +3,31 @@ package io.github.miuzarte.scrcpyforandroid.pages import android.annotation.SuppressLint import android.util.Log 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.Column 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.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.lazy.rememberLazyListState 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.Refresh import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.BlendModeColorFilter import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle 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.password.PasswordPickerPopupContent 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.services.AppRuntime 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.LocalEnableBlur 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.DeviceTileList import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard 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.VirtualButtonAction 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.Scaffold 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.TextField import top.yukonga.miuix.kmp.basic.TopAppBar 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.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles @@ -293,11 +273,14 @@ fun DeviceTabPage( var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) } val sessionInfo by scrcpy.currentSessionState.collectAsState() val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState() + val listingsRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState() var editingDeviceId by rememberSaveable { mutableStateOf(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf(null) } var adbConnecting by rememberSaveable { mutableStateOf(false) } var pendingScrollToPreview 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 isPreviewCardVisible by remember(listState) { derivedStateOf { @@ -341,9 +324,8 @@ fun DeviceTabPage( adbSession.connectedScrcpyProfileId val connectedScrcpyBundle = resolveScrcpyBundle(connectedScrcpyProfileId) - val audioForwardingSupported = adbSession.audioForwardingSupported - val cameraMirroringSupported = adbSession.cameraMirroringSupported - val recentTasks = scrcpy.listings.recentTasks + val apps = remember(listingsRefreshVersion) { scrcpy.listings.apps } + val recentTasks = remember(listingsRefreshVersion) { scrcpy.listings.recentTasks } val sessionReconnectBlacklistHosts = remember { mutableSetOf() } @@ -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) { mutableStateOf(qdBundle.quickConnectInput) } @@ -1011,21 +1055,26 @@ fun DeviceTabPage( busy = busy, activeProfileId = connectedScrcpyProfileId, activeBundle = connectedScrcpyBundle, - audioForwardingSupported = audioForwardingSupported, - cameraMirroringSupported = cameraMirroringSupported, + hideSimpleConfigItems = asBundle.hideSimpleConfigItems, + audioForwardingSupported = adbSession.audioForwardingSupported, + cameraMirroringSupported = adbSession.cameraMirroringSupported, adbConnecting = adbConnecting, isQuickConnected = isQuickConnected, - recentTasksEndAction = { - Text( - text = when { - listingsRefreshBusy -> "..." - recentTasks.isNotEmpty() -> recentTasks.size.toString() - else -> "空" - }, - color = colorScheme.onSurfaceVariantActions, - fontSize = textStyles.body2.fontSize, - modifier = Modifier.padding(end = UiSpacing.ContentVertical), - ) + allAppsEndActionText = when { + listingsRefreshBusy -> "..." + apps.isNotEmpty() -> apps.size.toString() + else -> "空" + }, + onOpenAllApps = { + showAllAppsSheet = true + if (apps.isEmpty() && !listingsRefreshBusy) { + scope.launch(Dispatchers.IO) { refreshApps() } + } + }, + recentTasksEndActionText = when { + listingsRefreshBusy -> "..." + recentTasks.isNotEmpty() -> recentTasks.size.toString() + else -> "空" }, onOpenRecentTasks = { showRecentTasksSheet = true @@ -1078,6 +1127,29 @@ fun DeviceTabPage( onOpenFullscreen = { 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, onAutoBringIntoViewConsumed = { pendingScrollToPreview = false }, ) @@ -1090,14 +1162,37 @@ fun DeviceTabPage( moreActions = virtualButtonLayout.second, showText = asBundle.previewVirtualButtonShowText, onAction = { action -> - if (action == VirtualButtonAction.PASSWORD_INPUT) { - snackbar.show("当前页面无法拉起验证") - return@VirtualButtonCard - } - val keycode = action.keycode ?: return@VirtualButtonCard - runBusy("发送 ${action.title}") { - scrcpy.injectKeycode(0, keycode) - scrcpy.injectKeycode(1, keycode) + when (action) { + VirtualButtonAction.RECENT_TASKS -> { + showRecentTasksSheet = true + if (recentTasks.isEmpty() && !listingsRefreshBusy) { + scope.launch(Dispatchers.IO) { refreshApps() } + scope.launch(Dispatchers.IO) { refreshRecentTasks() } + } + } + + 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 = @@ -1131,10 +1226,43 @@ fun DeviceTabPage( } - RecentTasksBottomSheet( + AppListBottomSheet( show = showRecentTasksSheet, - tasks = recentTasks, - scrcpy = scrcpy, + 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 + 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, onDismissRequest = { showRecentTasksSheet = false }, 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 @@ -1217,158 +1364,6 @@ private fun DeviceMenuPopup( } } -@Composable -private fun RecentTasksBottomSheet( - show: Boolean, - tasks: List, - 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 private fun DeviceMenuPopupItem( text: String, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 596f4bb..59cb575 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -3,6 +3,7 @@ package io.github.miuzarte.scrcpyforandroid.pages import android.annotation.SuppressLint import android.graphics.Rect import android.util.Log +import android.view.KeyEvent import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity 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.storage.Settings 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.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions @@ -83,6 +86,8 @@ fun FullscreenControlScreen( val snackbar = LocalSnackbarController.current 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 asBundleSharedLatest by rememberUpdatedState(asBundleShared) @@ -125,8 +130,13 @@ fun FullscreenControlScreen( moreActions = buttonItems.second, ) } + val recentTasks = remember(listingsRefreshVersion) { scrcpy.listings.recentTasks } + val apps = remember(listingsRefreshVersion) { scrcpy.listings.apps } 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) { 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( contentWindowInsets = WindowInsets(0, 0, 0, 0), snackbarHost = { SnackbarHost(snackbar.hostState) }, @@ -189,13 +259,16 @@ fun FullscreenControlScreen( ) { val session = currentSession ?: return@Box FullscreenControlPage( + scrcpy = scrcpy, session = session, onDismiss = onBack, showDebugInfo = fullscreenDebugInfo && !isInPip, currentFps = currentFps, + imeRequestToken = imeRequestToken, enableBackHandler = false, interactive = !isInPip, onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged, + onImeCommitText = ::commitImeText, onInjectTouch = { action, pointerId, x, y, pressure, buttons -> withContext(Dispatchers.IO) { scrcpy.injectTouch( @@ -217,10 +290,33 @@ fun FullscreenControlScreen( bar.Fullscreen( modifier = Modifier.align(Alignment.BottomCenter), onAction = { action -> - if (action == VirtualButtonAction.PASSWORD_INPUT) { - snackbar.show("当前页面无法拉起验证") - } else { - action.keycode?.let { sendKeycode(it) } + when (action) { + VirtualButtonAction.RECENT_TASKS -> { + showRecentTasksSheet = true + 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) { @@ -243,10 +339,33 @@ fun FullscreenControlScreen( actions = floatingActions, modifier = Modifier.fillMaxSize(), onAction = { action -> - if (action == VirtualButtonAction.PASSWORD_INPUT) { - snackbar.show("当前页面无法拉起验证") - } else { - action.keycode?.let { sendKeycode(it) } + when (action) { + VirtualButtonAction.RECENT_TASKS -> { + showRecentTasksSheet = true + 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) { @@ -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 fun FullscreenControlPage( + scrcpy: Scrcpy, session: Scrcpy.Session.SessionInfo, onDismiss: () -> Unit, showDebugInfo: Boolean, currentFps: Float, + imeRequestToken: Int = 0, enableBackHandler: Boolean = true, interactive: Boolean = true, onVideoBoundsInWindowChanged: (Rect?) -> Unit = {}, + onImeCommitText: suspend (String) -> Unit, onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit, ) { BackHandler(enabled = enableBackHandler, onBack = onDismiss) @@ -372,6 +565,27 @@ fun FullscreenControlPage( ) }, 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 + }, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ImeTextSubmission.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ImeTextSubmission.kt new file mode 100644 index 0000000..88f493d --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ImeTextSubmission.kt @@ -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) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt index 86581ba..2a686b5 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt @@ -9,16 +9,15 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing 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.Storage.quickDevices -import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -31,7 +30,6 @@ fun ReorderDevicesScreen( show: Boolean, onDismissRequest: () -> Unit, ) { - val scope = rememberCoroutineScope() val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val qdBundleShared by quickDevices.bundleState.collectAsState() diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt index 363e4ab..9255f4a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -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.NewDisplay 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.SuperSpinner 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.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop -import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -583,14 +583,11 @@ internal fun ScrcpyAllOptionsPage( soBundle.cameraSizeCustom, soBundle.cameraSizeUseCustom, ) { - if (cameraSizes.isNotEmpty()) { - null - } else if (soBundle.cameraSizeUseCustom && soBundle.cameraSizeCustom.isNotBlank()) { - soBundle.cameraSizeCustom - } else if (soBundle.cameraSize.isNotBlank()) { - soBundle.cameraSize - } else { - null + when { + cameraSizes.isNotEmpty() -> null + soBundle.cameraSizeUseCustom && soBundle.cameraSizeCustom.isNotBlank() -> soBundle.cameraSizeCustom + soBundle.cameraSize.isNotBlank() -> soBundle.cameraSize + else -> null } } @@ -1583,7 +1580,7 @@ internal fun ScrcpyAllOptionsPage( }, ) SwitchPreference( - title = "禁用结束后清理", + title = "禁用 MediaCodec 出错时自动降级", summary = "--no-downsize-on-error", checked = !soBundle.downsizeOnError, 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( title = "禁用结束后清理", summary = "--no-cleanup", diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt index dc1cd89..9f319b0 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -34,6 +34,7 @@ import io.github.miuzarte.scrcpyforandroid.LockscreenPasswordActivity import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing 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.SuperTextField 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.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop -import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -286,6 +286,14 @@ fun SettingsPage( asBundle = asBundle.copy(fullscreenDebugInfo = it) }, ) + SwitchPreference( + title = "设备页隐藏简单设置项", + summary = "启用后设备页仅保留更多参数、所有应用、最近任务和启动/停止按钮", + checked = asBundle.hideSimpleConfigItems, + onCheckedChange = { + asBundle = asBundle.copy(hideSimpleConfigItems = it) + }, + ) SuperSlider( title = "预览卡高度", summary = "设备页预览卡高度", @@ -343,6 +351,16 @@ fun SettingsPage( asBundle = asBundle.copy(showFullscreenFloatingButton = it) }, ) + SwitchPreference( + title = "实时同步剪贴板到受控机", + summary = "本机剪贴板更新后会自动同步到受控机" + + "\n禁用后需要使用虚拟按钮中的粘贴才能粘贴本机内容" + + "\nMIUI 完全不允许后台监听剪贴板,因此该选项在小米设备上可能无效", + checked = asBundle.realtimeClipboardSyncToDevice, + onCheckedChange = { + asBundle = asBundle.copy(realtimeClipboardSyncToDevice = it) + }, + ) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt index d9d8942..b146935 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt @@ -21,12 +21,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing 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.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop -import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/ReorderableList.kt similarity index 99% rename from app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt rename to app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/ReorderableList.kt index 66c5aa4..87667b7 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/ReorderableList.kt @@ -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.layout.Arrangement diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SectionSmallTitle.kt similarity index 52% rename from app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt rename to app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SectionSmallTitle.kt index fb6cde2..f416449 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SectionSmallTitle.kt @@ -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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import top.yukonga.miuix.kmp.basic.SmallTitle +import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme @Composable fun SectionSmallTitle( text: String, + modifier: Modifier = Modifier, + textColor: Color = colorScheme.onBackgroundVariant, insideMargin: PaddingValues = PaddingValues(16.dp, 8.dp), ) { SmallTitle( text = text, + modifier = modifier, + textColor = textColor, insideMargin = insideMargin, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt index a8fb335..d014cd1 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt @@ -153,13 +153,13 @@ data class ClientOptions( var disableScreensaver: Boolean = false, // var forwardKeyRepeat: Boolean, - // var legacyPaste: Boolean, + var legacyPaste: Boolean = false, // --power-off-on-close var powerOffOnClose: Boolean = false, // to server // --no-clipboard-autosync - // var clipboardAutosync: Boolean = true, // to server + var clipboardAutosync: Boolean = true, // to server // --no-downsize-on-error var downsizeOnError: Boolean = true, // to server @@ -555,6 +555,8 @@ data class ClientOptions( cameraFps = cameraFps, powerOffOnClose = powerOffOnClose, + legacyPaste = legacyPaste, + clipboardAutosync = clipboardAutosync, downsizeOnError = downsizeOnError, @@ -569,4 +571,4 @@ data class ClientOptions( list = list, ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index 2fa0fbc..36cd324 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -1,5 +1,6 @@ package io.github.miuzarte.scrcpyforandroid.scrcpy +import android.content.ClipboardManager import android.content.Context import android.net.Uri 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.ListOptions 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.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -61,7 +67,24 @@ class Scrcpy( 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(null) val currentSessionState: StateFlow = _currentSessionState.asStateFlow() @@ -143,8 +166,9 @@ class Scrcpy( } // Create session info - _currentSessionState.value = info + _currentSessionState.value = info.copy(legacyPaste = options.legacyPaste) isRunning = true + startClipboardSync() // Setup video consumer (notify NativeCoreFacade to setup decoders) if (options.video) { @@ -204,6 +228,7 @@ class Scrcpy( audioPlayer = null isRunning = false _currentSessionState.value = null + stopClipboardSync() Log.i(TAG, "stop(): Session stopped successfully") true } catch (e: Exception) { @@ -214,8 +239,6 @@ class Scrcpy( fun isStarted(): Boolean = isRunning && session.isStarted() - fun getCurrentSession(): Session.SessionInfo? = currentSessionState.value - suspend fun startApp(name: String) = session.startApp(name) suspend fun injectKeycode( @@ -232,6 +255,8 @@ class Scrcpy( suspend fun injectText(text: String) = session.injectText(text) + suspend fun setClipboard(text: String, paste: Boolean) = session.setClipboard(text, paste) + suspend fun injectTouch( action: Int, pointerId: Long, @@ -278,34 +303,69 @@ class Scrcpy( _currentSessionState.value = current.copy(width = width, height = height) } - sealed class ListResult { - data class Encoders( - val videoEncoders: List, - val audioEncoders: List, - val videoEncoderTypes: Map = emptyMap(), - val audioEncoderTypes: Map = emptyMap(), - val rawOutput: String = "", - ) : ListResult() + private fun startClipboardSync() { + synchronized(clipboardSyncLock) { + lastKnownLocalClipboardText = LocalInputService.getClipboardText(appContext) + pendingRemoteClipboardText = null + if (!clipboardListenerRegistered) { + clipboardManager?.addPrimaryClipChangedListener(clipboardChangedListener) + clipboardListenerRegistered = true + } + } + } - data class Displays( - val displays: List, - val rawOutput: String = "", - ) : ListResult() + private fun stopClipboardSync() { + synchronized(clipboardSyncLock) { + if (clipboardListenerRegistered) { + clipboardManager?.removePrimaryClipChangedListener(clipboardChangedListener) + clipboardListenerRegistered = false + } + pendingRemoteClipboardText = null + } + } - data class Cameras( - val cameras: List, - val rawOutput: String = "", - ) : ListResult() + private fun handleLocalClipboardChanged() { + val text = LocalInputService.getClipboardText(appContext) + val shouldSync = synchronized(clipboardSyncLock) { + 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( - val sizes: List, - val rawOutput: String = "", - ) : ListResult() - - data class Apps( - val apps: List, - val rawOutput: String = "", - ) : ListResult() + private fun handleRemoteClipboardText(text: String) { + val shouldWrite = synchronized(clipboardSyncLock) { + if (text == lastKnownLocalClipboardText) { + return@synchronized false + } + pendingRemoteClipboardText = text + lastKnownLocalClipboardText = text + true + } + if (!shouldWrite) { + return + } + LocalInputService.setClipboardText(appContext, text) } inner class Listings { @@ -740,7 +800,9 @@ class Scrcpy( * Session manager for scrcpy protocol. * Handles socket communication, video/audio streaming, and control input. */ - class Session { + class Session( + private val onRemoteClipboardText: (String) -> Unit, + ) { private val mutex = Mutex() @Volatile @@ -758,6 +820,9 @@ class Scrcpy( @Volatile private var audioReaderThread: Thread? = null + @Volatile + private var controlReaderThread: Thread? = null + private val serverLogBuffer = ArrayDeque() suspend fun start( @@ -881,6 +946,10 @@ class Scrcpy( val controlWriter = controlStream?.let { stream -> ControlWriter(DataOutputStream(stream.outputStream)) } + val controlInput = controlStream?.let { stream -> + if (stream === firstStream) firstInput + else DataInputStream(BufferedInputStream(stream.inputStream)) + } val newSession = ActiveSession( info = sessionInfo, @@ -892,9 +961,13 @@ class Scrcpy( audioStream = audioStream, audioInput = audioInput, controlStream = controlStream, + controlInput = controlInput, controlWriter = controlWriter, ) activeSession = newSession + if (options.control && options.clipboardAutosync) + startControlReaderThread(newSession) + return sessionInfo } catch (t: Throwable) { 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( action: Int, pointerId: Long, @@ -1121,6 +1202,12 @@ class Scrcpy( } audioReaderThread = null + if (Thread.currentThread() !== controlReaderThread) { + runCatching { controlReaderThread?.interrupt() } + runCatching { controlReaderThread?.join(300) } + } + controlReaderThread = null + runCatching { session.controlStream?.close() } runCatching { session.audioStream?.close() } runCatching { session.videoStream?.close() } @@ -1140,6 +1227,57 @@ class Scrcpy( ?: 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( serverStream: AdbSocketStream, socketName: String @@ -1223,6 +1361,7 @@ class Scrcpy( val height: Int, val audioCodecId: Int = 0, val controlEnabled: Boolean, + val legacyPaste: Boolean = false, val host: String = "", val port: Int = Defaults.ADB_PORT, ) @@ -1286,6 +1425,7 @@ class Scrcpy( val audioStream: AdbSocketStream?, val audioInput: DataInputStream?, val controlStream: AdbSocketStream?, + val controlInput: DataInputStream?, val controlWriter: ControlWriter?, ) @@ -1309,6 +1449,17 @@ class Scrcpy( 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 fun injectTouch( action: Int, @@ -1415,14 +1566,20 @@ class Scrcpy( private const val AUDIO_DISABLED = 0 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_TEXT = 1 private const val TYPE_INJECT_TOUCH_EVENT = 2 private const val TYPE_INJECT_SCROLL_EVENT = 3 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_START_APP = 16 + private const val CLIPBOARD_SEQUENCE_INVALID = 0L private fun socketNameFor(scid: Int): String { return "scrcpy_%08x".format(scid) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt index 8fc1c47..b79b5b2 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt @@ -72,6 +72,8 @@ data class ServerParams( // var forceAdbForward: Boolean, var powerOffOnClose: Boolean, + var legacyPaste: Boolean, + var clipboardAutosync: Boolean, var downsizeOnError: Boolean, // var tcpip: Boolean, @@ -236,9 +238,10 @@ data class ServerParams( if (powerOffOnClose) { cmd.add("power_off_on_close=true") } - // not implemented - val clipBoardAutosync = false - if (!clipBoardAutosync) { + if (legacyPaste) { + cmd.add("legacy_paste=true") + } + if (!clipboardAutosync) { cmd.add("clipboard_autosync=false") } if (!downsizeOnError) { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/LocalInputService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/LocalInputService.kt new file mode 100644 index 0000000..3e13d1f --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/LocalInputService.kt @@ -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)) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index 4b2f8a8..6d9f31e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -62,6 +62,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { floatPreferencesKey("fullscreen_floating_button_y_fraction"), 0.72f ) + val HIDE_SIMPLE_CONFIG_ITEMS = Pair( + booleanPreferencesKey("hide_simple_config_items"), + false, + ) val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( intPreferencesKey("device_preview_card_height_dp"), 1080 / 3 @@ -72,7 +76,21 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { ) val VIRTUAL_BUTTONS_LAYOUT = Pair( 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( stringPreferencesKey("custom_server_uri"), @@ -111,6 +129,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { booleanPreferencesKey("password_require_auth"), true ) + val REALTIME_CLIPBOARD_SYNC_TO_DEVICE = Pair( + booleanPreferencesKey("realtime_clipboard_sync_to_device"), + true + ) val LAST_UPDATE_CHECK_AT = Pair( longPreferencesKey("last_update_check_at"), 0L @@ -132,6 +154,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val showFullscreenFloatingButton by setting(SHOW_FULLSCREEN_FLOATING_BUTTON) val fullscreenFloatingButtonXFraction by setting(FULLSCREEN_FLOATING_BUTTON_X_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 previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) 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 adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH) + val realtimeClipboardSyncToDevice by setting(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT) @Parcelize @@ -164,6 +188,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val showFullscreenFloatingButton: Boolean, val fullscreenFloatingButtonXFraction: Float, val fullscreenFloatingButtonYFraction: Float, + val hideSimpleConfigItems: Boolean, val devicePreviewCardHeightDp: Int, val previewVirtualButtonShowText: Boolean, val virtualButtonsLayout: String, @@ -176,6 +201,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val adbMdnsLanDiscovery: Boolean, val adbAutoLoadAppListOnConnect: Boolean, val passwordRequireAuth: Boolean, + val realtimeClipboardSyncToDevice: Boolean, val lastUpdateCheckAt: Long, ) : Parcelable { } @@ -193,6 +219,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { bundleField(SHOW_FULLSCREEN_FLOATING_BUTTON) { bundle: Bundle -> bundle.showFullscreenFloatingButton }, bundleField(FULLSCREEN_FLOATING_BUTTON_X_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonXFraction }, 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(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText }, 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_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect }, 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 }, ) @@ -223,6 +251,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { showFullscreenFloatingButton = preferences.read(SHOW_FULLSCREEN_FLOATING_BUTTON), fullscreenFloatingButtonXFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_X_FRACTION), fullscreenFloatingButtonYFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION), + hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS), devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP), previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT), virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT), @@ -237,6 +266,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY), adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT), passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH), + realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE), lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT), ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt index 87c8e3e..48d31af 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -188,6 +188,14 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { booleanPreferencesKey("power_off_on_close"), false, ) + val LEGACY_PASTE = Pair( + booleanPreferencesKey("legacy_paste"), + false, + ) + val CLIPBOARD_AUTOSYNC = Pair( + booleanPreferencesKey("clipboard_autosync"), + true, + ) val DOWNSIZE_ON_ERROR = Pair( booleanPreferencesKey("downsize_on_error"), true, @@ -294,6 +302,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { stayAwake = STAY_AWAKE.defaultValue, disableScreensaver = DISABLE_SCREENSAVER.defaultValue, powerOffOnClose = POWER_OFF_ON_CLOSE.defaultValue, + legacyPaste = LEGACY_PASTE.defaultValue, + clipboardAutosync = CLIPBOARD_AUTOSYNC.defaultValue, downsizeOnError = DOWNSIZE_ON_ERROR.defaultValue, cleanup = CLEANUP.defaultValue, powerOn = POWER_ON.defaultValue, @@ -353,6 +363,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { val stayAwake by setting(STAY_AWAKE) val disableScreensaver by setting(DISABLE_SCREENSAVER) 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 powerOn by setting(POWER_ON) val video by setting(VIDEO) @@ -409,6 +421,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { val stayAwake: Boolean, val disableScreensaver: Boolean, val powerOffOnClose: Boolean, + val legacyPaste: Boolean, + val clipboardAutosync: Boolean, val downsizeOnError: Boolean, val cleanup: Boolean, val powerOn: Boolean, @@ -469,6 +483,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake }, bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver }, 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(POWER_ON) { bundle: Bundle -> bundle.powerOn }, bundleField(VIDEO) { bundle: Bundle -> bundle.video }, @@ -529,6 +545,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { stayAwake = preferences.read(STAY_AWAKE), disableScreensaver = preferences.read(DISABLE_SCREENSAVER), powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE), + legacyPaste = preferences.read(LEGACY_PASTE), + clipboardAutosync = preferences.read(CLIPBOARD_AUTOSYNC), downsizeOnError = preferences.read(DOWNSIZE_ON_ERROR), cleanup = preferences.read(CLEANUP), powerOn = preferences.read(POWER_ON), @@ -604,6 +622,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { stayAwake = bundle.stayAwake, disableScreensaver = bundle.disableScreensaver, powerOffOnClose = bundle.powerOffOnClose, + legacyPaste = bundle.legacyPaste, + clipboardAutosync = bundle.clipboardAutosync, downsizeOnError = bundle.downsizeOnError, cleanup = bundle.cleanup, powerOn = bundle.powerOn, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt new file mode 100644 index 0000000..be0b344 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt @@ -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, + 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, + ) + } + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 1f5fb42..cc20444 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -1,9 +1,9 @@ package io.github.miuzarte.scrcpyforandroid.widgets import android.annotation.SuppressLint +import android.view.KeyEvent import android.view.Surface import android.view.SurfaceHolder -import android.view.SurfaceView import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState 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.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight @@ -86,6 +85,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Button 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.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor +import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles import top.yukonga.miuix.kmp.utils.PressFeedbackType import kotlin.math.roundToInt @@ -253,6 +255,10 @@ internal fun PreviewCard( sessionInfo: Scrcpy.Session.SessionInfo?, previewHeightDp: Int, 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, onAutoBringIntoViewConsumed: () -> Unit = {}, ) { @@ -335,6 +341,10 @@ internal fun PreviewCard( ScrcpyVideoSurface( modifier = Modifier.fillMaxSize(), session = sessionInfo, + imeRequestToken = imeRequestToken, + onImeCommitText = onImeCommitText, + onImeDeleteSurroundingText = onImeDeleteSurroundingText, + onImeKeyEvent = onImeKeyEvent, ) } } @@ -401,14 +411,17 @@ internal fun ConfigPanel( busy: Boolean, activeProfileId: String, activeBundle: ScrcpyOptions.Bundle, + hideSimpleConfigItems: Boolean, audioForwardingSupported: Boolean, cameraMirroringSupported: Boolean, adbConnecting: Boolean, isQuickConnected: Boolean, - recentTasksEndAction: @Composable (RowScope.() -> Unit)? = null, - onOpenRecentTasks: (() -> Unit)? = null, + allAppsEndActionText: String, + onOpenAllApps: () -> Unit, + recentTasksEndActionText: String, + onOpenRecentTasks: () -> Unit, onOpenAdvanced: () -> Unit, - onStartStopHaptic: (() -> Unit)? = null, + onStartStopHaptic: () -> Unit, onStart: () -> Unit, onStop: () -> Unit, sessionInfo: Scrcpy.Session.SessionInfo?, @@ -470,149 +483,167 @@ internal fun ConfigPanel( } Card { - SwitchPreference( - title = "音频转发", - summary = "转发设备音频到本机 (Android 11+)", - checked = soBundle.audio, - onCheckedChange = { soBundle = soBundle.copy(audio = it) }, - enabled = !sessionStarted - && audioForwardingSupported, - ) + if (!hideSimpleConfigItems) { + SwitchPreference( + title = "音频转发", + summary = "转发设备音频到本机 (Android 11+)", + checked = soBundle.audio, + onCheckedChange = { soBundle = soBundle.copy(audio = it) }, + enabled = !sessionStarted + && audioForwardingSupported, + ) - OverlayDropdownPreference( - title = "音频编码", - summary = "--audio-codec", - items = audioCodecItems, - selectedIndex = audioCodecIndex, - onSelectedIndexChange = { - val codec = Codec.AUDIO[it] - soBundle = soBundle.copy(audioCodec = codec.string) - if (codec == Codec.FLAC) { - snackbar.show("注意:FLAC编解码会引入较大的延迟") - } - }, - enabled = !sessionStarted && soBundle.audio, - ) - AnimatedVisibility(audioBitRateVisibility) { + OverlayDropdownPreference( + title = "音频编码", + summary = "--audio-codec", + items = audioCodecItems, + selectedIndex = audioCodecIndex, + onSelectedIndexChange = { + val codec = Codec.AUDIO[it] + soBundle = soBundle.copy(audioCodec = codec.string) + if (codec == Codec.FLAC) { + snackbar.show("注意:FLAC编解码会引入较大的延迟") + } + }, + enabled = !sessionStarted && soBundle.audio, + ) + 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( - title = "音频码率", - summary = "--audio-bit-rate", - value = ScrcpyPresets.AudioBitRate - .indexOfOrNearest(soBundle.audioBitRate / 1000) - .toFloat(), - onValueChange = { value -> - val idx = value.roundToInt() - .coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) + title = "视频码率", + summary = "--video-bit-rate", + value = soBundle.videoBitRate / 1_000_000f, + onValueChange = { mbps -> soBundle = soBundle.copy( - audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 + videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10) ) }, - valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), - steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), - unit = "Kbps", + valueRange = 0f..40f, + steps = 400 - 1, + unit = "Mbps", zeroStateText = "默认", - displayText = (soBundle.audioBitRate / 1_000).toString(), + displayFormatter = { String.format("%.1f", it) }, inputInitialValue = - if (soBundle.audioBitRate <= 0) "" - else (soBundle.audioBitRate / 1_000).toString(), - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), + 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.toIntOrNull() - ?.takeIf { it >= 0 } - ?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) } + raw.toFloatOrNull()?.let { parsed -> + if (parsed >= 0f) { + soBundle = soBundle.copy( + videoBitRate = (parsed * 1_000_000f).roundToInt() + ) + } + } }, 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( - title = "更多参数", + title = if (!hideSimpleConfigItems) "更多参数" else "所有参数", summary = "所有 scrcpy 参数", onClick = onOpenAdvanced, enabled = !sessionStarted, ) - if (onOpenRecentTasks != null) { - ArrowPreference( - title = "最近任务", - endActions = { - recentTasksEndAction?.invoke(this) - }, - onClick = onOpenRecentTasks, - enabled = !busy && !adbConnecting, - ) - } + + ArrowPreference( + title = "所有应用", + endActions = { + Text( + text = allAppsEndActionText, + color = colorScheme.onSurfaceVariantActions, + 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( modifier = Modifier .fillMaxWidth() - .padding(horizontal = UiSpacing.ContentVertical) - .padding(bottom = UiSpacing.ContentVertical), + .padding(all = UiSpacing.ContentVertical), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { if (isQuickConnected) TextButton( text = "断开", onClick = { - onStartStopHaptic?.invoke() + onStartStopHaptic() onDisconnect() }, modifier = Modifier.weight(1f / 4f), @@ -621,7 +652,7 @@ internal fun ConfigPanel( if (!sessionStarted) TextButton( text = "启动", onClick = { - onStartStopHaptic?.invoke() + onStartStopHaptic() onStart() }, modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), @@ -631,7 +662,7 @@ internal fun ConfigPanel( if (sessionStarted) TextButton( text = "停止", onClick = { - onStartStopHaptic?.invoke() + onStartStopHaptic() onStop() }, modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f), @@ -813,14 +844,19 @@ private fun PairingDialog( fun ScrcpyVideoSurface( modifier: Modifier, 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 scope = rememberCoroutineScope() + val imeInputMutex = remember { Mutex() } val lifecycleOwner = LocalLifecycleOwner.current var currentSurface by remember { mutableStateOf(null) } - var currentSurfaceView by remember { mutableStateOf(null) } + var currentSurfaceView by remember { mutableStateOf(null) } 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) { val surface = currentSurface if ( @@ -882,8 +925,42 @@ fun ScrcpyVideoSurface( AndroidView( modifier = modifier, factory = { context -> - SurfaceView(context).apply { + ScrcpyInputSurfaceView(context).apply { 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 { override fun surfaceCreated(holder: SurfaceHolder) { val newSurface = holder.surface diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ScrcpyInputSurfaceView.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ScrcpyInputSurfaceView.kt new file mode 100644 index 0000000..1da5d23 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ScrcpyInputSurfaceView.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt index f2c629a..e2c66b1 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -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.VolumeUp 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.Keyboard import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Notifications @@ -145,6 +148,30 @@ enum class VirtualButtonAction( "填充锁屏密码", Icons.Rounded.Password, 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 ); }