From a3ba7fc9183bbc3550c047d2a70792e9972292da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AC=AC=E7=B4=97=E7=89=B9?= <66856838+Miuzarte@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:01:00 +0800 Subject: [PATCH] refactor: introducing ViewModel (#17) * move to viewModel; use submodule to import miuix * replace widgets with which implemented in miuix * update CHANGELOG.md * fix: coroutine handling * chore: code formatting * viewModel for TerminalScreen * viewModel for FileManagerScreen * chore: code formatting * fix for review * chore: optimize imports --- .gitignore | 1 + .gitmodules | 3 + CHANGELOG.md | 8 +- README.md | 8 + app/build.gradle.kts | 1 + .../OverlaySpinnerPreference.kt} | 61 +- .../scrcpyforandroid/miuix/SpinnerEntry.kt | 13 + .../scrcpyforandroid/miuix/SpinnerItemImpl.kt | 134 ++ .../scrcpyforandroid/pages/AboutScreen.kt | 100 +- .../pages/DeviceTabAdbCallbacks.kt | 136 -- .../scrcpyforandroid/pages/DeviceTabScreen.kt | 1123 +++-------------- .../pages/DeviceTabViewModel.kt | 954 ++++++++++++++ .../pages/FileManagerScreen.kt | 916 ++++---------- .../pages/FileManagerViewModel.kt | 486 +++++++ .../pages/FullscreenControlScreen.kt | 241 ++-- .../scrcpyforandroid/pages/MainScreen.kt | 8 +- .../pages/ScrcpyAllOptionsScreen.kt | 274 ++-- .../scrcpyforandroid/pages/SettingsScreen.kt | 22 +- .../scrcpyforandroid/pages/TerminalScreen.kt | 561 +++----- .../pages/TerminalViewModel.kt | 277 ++++ .../scaffolds/OverlaySpinnerWithFallback.kt | 97 ++ .../scrcpyforandroid/scrcpy/ClientOptions.kt | 1 - .../scrcpyforandroid/storage/BundleSync.kt | 120 ++ .../widgets/MultiGroupsDropdown.kt | 137 -- .../scrcpyforandroid/widgets/PopupMenuItem.kt | 46 - gradle/libs.versions.toml | 1 + settings.gradle.kts | 1 + submodule/miuix | 1 + 28 files changed, 3015 insertions(+), 2716 deletions(-) create mode 100644 .gitmodules rename app/src/main/java/io/github/miuzarte/scrcpyforandroid/{scaffolds/SuperSpinner.kt => miuix/OverlaySpinnerPreference.kt} (78%) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/miuix/SpinnerEntry.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/miuix/SpinnerItemImpl.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabAdbCallbacks.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerViewModel.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalViewModel.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/OverlaySpinnerWithFallback.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/BundleSync.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/PopupMenuItem.kt create mode 160000 submodule/miuix diff --git a/.gitignore b/.gitignore index 780cf30..be25ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ google-services.json # Android Profiling *.hprof +.claude/ .kiro/ .kotlin/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..89c5050 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "submodule/miuix"] + path = submodule/miuix + url = https://github.com/compose-miuix-ui/miuix.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b14c8..257e773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Change Log -~~什么我居然开始写更新日志了~~ +## 0.3.0 + +该版本主要进行代码重构,引入 ViewModel 以优化代码结构 + +- 重构: 部分自行实现的组件迁移至 miuix + - 引入子模块以在本地编译 miuix 的最新提交 +- 新增: 所有参数页中的五个列表展开即触发懒加载 ## 0.2.9 diff --git a/README.md b/README.md index 6e53250..65f6c6a 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,17 @@ - Android NDK `29.0.14206865` ```bash +git clone --recursive https://github.com/Miuzarte/ScrcpyForAndroid.git +cd ScrcpyForAndroid ./gradlew assembleDebug ``` +已克隆但没有拉取子模块: + +```bash +git submodule update --init --recursive +``` + specific abi: ```bash diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e01813..d997b2b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -126,6 +126,7 @@ androidComponents { dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.pip) implementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSpinner.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/miuix/OverlaySpinnerPreference.kt similarity index 78% rename from app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSpinner.kt rename to app/src/main/java/io/github/miuzarte/scrcpyforandroid/miuix/OverlaySpinnerPreference.kt index 7381e8b..aecc92c 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSpinner.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/miuix/OverlaySpinnerPreference.kt @@ -1,4 +1,4 @@ -package io.github.miuzarte.scrcpyforandroid.scaffolds +package io.github.miuzarte.scrcpyforandroid.miuix import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues @@ -26,14 +26,17 @@ import top.yukonga.miuix.kmp.basic.ListPopupColumn import top.yukonga.miuix.kmp.basic.PopupPositionProvider 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.SpinnerItemImpl import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.overlay.OverlayListPopup import top.yukonga.miuix.kmp.theme.MiuixTheme +/** + * Copy of miuix [OverlaySpinnerPreference] (popup mode) using our custom + * [SpinnerEntry] with per-item [SpinnerEntry.enabled] passed through to + * [SpinnerItemImpl]. + */ @Composable -fun SuperSpinner( +fun OverlaySpinnerPreference( items: List, selectedIndex: Int, title: String, @@ -49,21 +52,26 @@ fun SuperSpinner( enabled: Boolean = true, showValue: Boolean = true, renderInRootScaffold: Boolean = true, + onExpandedChange: ((Boolean) -> Unit)? = null, onSelectedIndexChange: ((Int) -> Unit)? = null, - overrideEndActionValue: String? = null, ) { val interactionSource = remember { MutableInteractionSource() } val isDropdownExpanded = rememberSaveable { mutableStateOf(false) } val isHoldDown = remember { mutableStateOf(false) } - val haptic = LocalHapticFeedback.current - val hapticLatest by rememberUpdatedState(haptic) + val hapticFeedback = LocalHapticFeedback.current + val currentHapticFeedback by rememberUpdatedState(hapticFeedback) + val currentOnExpandedChange = rememberUpdatedState(onExpandedChange) + val setExpanded: (Boolean) -> Unit = remember { + { expanded -> + if (isDropdownExpanded.value != expanded) { + isDropdownExpanded.value = expanded + currentOnExpandedChange.value?.invoke(expanded) + } + } + } val itemsNotEmpty = items.isNotEmpty() val actualEnabled = enabled && itemsNotEmpty - val forceOverrideValue = overrideEndActionValue != null - val endActionText = overrideEndActionValue - ?.takeIf { it.isNotBlank() } - ?: if (showValue && itemsNotEmpty) items[selectedIndex].title.orEmpty() else null val actionColor = if (actualEnabled) { MiuixTheme.colorScheme.onSurfaceVariantActions @@ -74,10 +82,10 @@ fun SuperSpinner( val handleClick = remember(actualEnabled) { { if (actualEnabled) { - isDropdownExpanded.value = !isDropdownExpanded.value + setExpanded(!isDropdownExpanded.value) if (isDropdownExpanded.value) { isHoldDown.value = true - hapticLatest.performHapticFeedback(HapticFeedbackType.ContextClick) + currentHapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) } } } @@ -93,9 +101,9 @@ fun SuperSpinner( summaryColor = summaryColor, startAction = startAction, endActions = { - if (endActionText != null) { + if (showValue && itemsNotEmpty) { Text( - text = endActionText, + text = items[selectedIndex].title ?: "", modifier = Modifier .padding(end = 8.dp) .align(Alignment.CenterVertically) @@ -105,22 +113,19 @@ fun SuperSpinner( textAlign = TextAlign.End, ) } - DropdownArrowEndAction( - actionColor = actionColor, - ) + DropdownArrowEndAction(actionColor = actionColor) if (itemsNotEmpty) { - SuperSpinnerPopup( + OverlaySpinnerPopup( items = items, selectedIndex = selectedIndex, isDropdownExpanded = isDropdownExpanded.value, - onDismiss = { isDropdownExpanded.value = false }, + onDismiss = { setExpanded(false) }, onDismissFinished = { isHoldDown.value = false }, maxHeight = maxHeight, - hapticFeedback = haptic, + hapticFeedback = hapticFeedback, spinnerColors = spinnerColors, renderInRootScaffold = renderInRootScaffold, onSelectedIndexChange = onSelectedIndexChange, - forceNoSelectedState = forceOverrideValue, ) } }, @@ -132,7 +137,7 @@ fun SuperSpinner( } @Composable -private fun SuperSpinnerPopup( +private fun OverlaySpinnerPopup( items: List, selectedIndex: Int, isDropdownExpanded: Boolean, @@ -143,14 +148,13 @@ private fun SuperSpinnerPopup( spinnerColors: SpinnerColors, renderInRootScaffold: Boolean, onSelectedIndexChange: ((Int) -> Unit)?, - forceNoSelectedState: Boolean, ) { - val haptic = LocalHapticFeedback.current val onSelectState = rememberUpdatedState(onSelectedIndexChange) val currentOnDismiss by rememberUpdatedState(onDismiss) + val currentHapticFeedback by rememberUpdatedState(hapticFeedback) val onItemSelected: (Int) -> Unit = remember { { selectedIdx -> - haptic.performHapticFeedback(HapticFeedbackType.Confirm) + currentHapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) onSelectState.value?.invoke(selectedIdx) currentOnDismiss() } @@ -169,14 +173,15 @@ private fun SuperSpinnerPopup( SpinnerItemImpl( entry = spinnerEntry, entryCount = items.size, - isSelected = !forceNoSelectedState && selectedIndex == index, + isSelected = selectedIndex == index, index = index, spinnerColors = spinnerColors, dialogMode = false, + enabled = spinnerEntry.enabled, onSelectedIndexChange = onItemSelected, ) } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/miuix/SpinnerEntry.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/miuix/SpinnerEntry.kt new file mode 100644 index 0000000..bd89525 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/miuix/SpinnerEntry.kt @@ -0,0 +1,13 @@ +package io.github.miuzarte.scrcpyforandroid.miuix + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier + +@Immutable +data class SpinnerEntry( + val icon: @Composable ((Modifier) -> Unit)? = null, + val title: String? = null, + val summary: String? = null, + val enabled: Boolean = true, +) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/miuix/SpinnerItemImpl.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/miuix/SpinnerItemImpl.kt new file mode 100644 index 0000000..b21bf6a --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/miuix/SpinnerItemImpl.kt @@ -0,0 +1,134 @@ +package io.github.miuzarte.scrcpyforandroid.miuix + +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import top.yukonga.miuix.kmp.basic.SpinnerColors +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme + +@Composable +fun SpinnerItemImpl( + entry: SpinnerEntry, + entryCount: Int, + isSelected: Boolean, + index: Int, + spinnerColors: SpinnerColors, + dialogMode: Boolean = false, + enabled: Boolean = true, + onSelectedIndexChange: (Int) -> Unit, +) { + val additionalTopPadding = if (!dialogMode && index == 0) 20.dp else 12.dp + val additionalBottomPadding = if (!dialogMode && index == entryCount - 1) 20.dp else 12.dp + + val disabled = !enabled + val (titleColor, summaryColor, backgroundColor) = when { + disabled -> Triple( + MiuixTheme.colorScheme.disabledOnSecondaryVariant, + MiuixTheme.colorScheme.disabledOnSecondaryVariant, + spinnerColors.containerColor, + ) + + isSelected -> Triple( + spinnerColors.selectedContentColor, + spinnerColors.selectedSummaryColor, + spinnerColors.selectedContainerColor, + ) + + else -> Triple( + spinnerColors.contentColor, + spinnerColors.summaryColor, + spinnerColors.containerColor, + ) + } + + val selectColor = when { + disabled -> Color.Transparent + isSelected -> spinnerColors.selectedIndicatorColor + else -> Color.Transparent + } + + val currentOnSelectedIndexChange by rememberUpdatedState(onSelectedIndexChange) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .drawBehind { drawRect(backgroundColor) } + .clickable(enabled = !disabled) { currentOnSelectedIndexChange(index) } + .then( + if (dialogMode) { + Modifier + .heightIn(min = 56.dp) + .widthIn(min = 200.dp) + .fillMaxWidth() + .padding(horizontal = 28.dp) + } else { + Modifier.padding(horizontal = 20.dp) + }, + ) + .padding(top = additionalTopPadding, bottom = additionalBottomPadding), + ) { + Row( + modifier = if (dialogMode) Modifier else Modifier.widthIn(max = 216.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + entry.icon?.let { + it( + Modifier + .sizeIn(minWidth = 26.dp, minHeight = 26.dp) + .padding(end = 12.dp), + ) + } + Column { + entry.title?.let { + Text( + text = it, + fontSize = MiuixTheme.textStyles.body1.fontSize, + fontWeight = FontWeight.Medium, + color = titleColor, + ) + } + entry.summary?.let { + Text( + text = it, + fontSize = MiuixTheme.textStyles.body2.fontSize, + color = summaryColor, + ) + } + } + } + if (!disabled) { + Box( + modifier = Modifier + .sizeIn(minWidth = 8.dp, minHeight = 8.dp) + .padding( + start = if (dialogMode) 20.dp else 12.dp, + top = 1.dp, + bottom = 1.dp, + ) + .clip(RectangleShape) + .background(selectColor), + ) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt index 0e637c8..eabf336 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AboutScreen.kt @@ -64,7 +64,6 @@ import top.yukonga.miuix.kmp.blur.BlendColorEntry import top.yukonga.miuix.kmp.blur.BlurBlendMode import top.yukonga.miuix.kmp.blur.BlurColors import top.yukonga.miuix.kmp.blur.BlurDefaults -import top.yukonga.miuix.kmp.blur.LayerBackdrop import top.yukonga.miuix.kmp.blur.isRenderEffectSupported import top.yukonga.miuix.kmp.blur.layerBackdrop import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop @@ -197,6 +196,31 @@ private fun AboutContent( .collect {} } + @Composable + fun AboutCard( + modifier: Modifier = Modifier.padding(horizontal = 12.dp), + content: @Composable () -> Unit, + ) { + Card( + modifier = modifier.textureBlur( + backdrop = backdrop, + shape = SmoothRoundedCornerShape(16.dp), + blurRadius = 60f, + noiseCoefficient = BlurDefaults.NoiseCoefficient, + colors = BlurColors(blendColors = cardBlendColors), + enabled = blurEnabled, + ), + colors = CardDefaults.defaultColors( + color = + if (blurEnabled) Color.Transparent + else colorScheme.surfaceContainer, + contentColor = Color.Transparent, + ), + ) { + content() + } + } + BgEffectBackground( dynamicBackground = true, modifier = Modifier.fillMaxSize(), @@ -348,12 +372,7 @@ private fun AboutContent( .padding(bottom = padding.calculateBottomPadding()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - AboutCard( - modifier = Modifier.padding(horizontal = 12.dp), - backdrop = backdrop, - blurEnabled = blurEnabled, - blendColors = cardBlendColors, - ) { + AboutCard { ArrowPreference( title = "项目仓库", endActions = { @@ -382,17 +401,15 @@ private fun AboutContent( }, onClick = { context.startActivity( - Intent(Intent.ACTION_VIEW, releasesUrl.toUri()) + Intent( + Intent.ACTION_VIEW, + releasesUrl.toUri(), + ) ) }, ) } - AboutCard( - modifier = Modifier.padding(horizontal = 12.dp), - backdrop = backdrop, - blurEnabled = blurEnabled, - blendColors = cardBlendColors, - ) { + AboutCard { ArrowPreference( title = "License", endActions = { @@ -406,46 +423,43 @@ private fun AboutContent( context.startActivity( Intent( Intent.ACTION_VIEW, - "https://www.apache.org/licenses/LICENSE-2.0.txt".toUri() + "https://www.apache.org/licenses/LICENSE-2.0.txt".toUri(), ) ) }, ) } + AboutCard { + listOf( + Pair("Miuix", "https://github.com/compose-miuix-ui/miuix"), + Pair("scrcpy", "https://github.com/Genymobile/scrcpy"), + ).forEach { (name, repo) -> + ArrowPreference( + title = name, + endActions = { + Text( + text = "GitHub", + fontSize = textStyles.body2.fontSize, + color = colorScheme.onSurfaceVariantActions, + ) + }, + onClick = { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + repo.toUri(), + ) + ) + }, + ) + } + } } } } } } -@Composable -private fun AboutCard( - modifier: Modifier, - backdrop: LayerBackdrop, - blurEnabled: Boolean, - blendColors: List, - content: @Composable () -> Unit, -) { - Card( - modifier = modifier.textureBlur( - backdrop = backdrop, - shape = SmoothRoundedCornerShape(16.dp), - blurRadius = 60f, - noiseCoefficient = BlurDefaults.NoiseCoefficient, - colors = BlurColors(blendColors = blendColors), - enabled = blurEnabled, - ), - colors = CardDefaults.defaultColors( - color = - if (blurEnabled) Color.Transparent - else colorScheme.surfaceContainer, - contentColor = Color.Transparent, - ), - ) { - content() - } -} - private fun heroBlendColors(isDark: Boolean): List = if (isDark) listOf( BlendColorEntry(Color(0xE6A1A1A1), BlurBlendMode.ColorDodge), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabAdbCallbacks.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabAdbCallbacks.kt deleted file mode 100644 index 051dbf9..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabAdbCallbacks.kt +++ /dev/null @@ -1,136 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.pages - -import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget -import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut -import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions - -internal class DeviceTabAdbCallbacks( - private val runAdbConnect: ( - label: String, - onStarted: (() -> Unit)?, - onFinished: (() -> Unit)?, - block: suspend () -> Unit, - ) -> Unit, - private val runBusy: ( - label: String, - onFinished: (() -> Unit)?, - block: suspend () -> Unit, - ) -> Unit, - private val disconnectCurrentTargetBeforeConnecting: suspend (host: String, port: Int) -> Unit, - private val connectWithTimeout: suspend (host: String, port: Int) -> Unit, - private val handleAdbConnected: suspend ( - host: String, - port: Int, - autoStartScrcpy: Boolean, - autoEnterFullScreen: Boolean, - scrcpyProfileId: String, - ) -> Unit, - private val disconnectAdbConnection: suspend ( - clearQuickOnlineForTarget: ConnectionTarget?, - logMessage: String?, - showSnackMessage: String?, - ) -> Unit, - private val discoverPairingTarget: suspend () -> Pair?, - private val pairTarget: suspend (host: String, port: Int, code: String) -> Boolean, - private val isConnectedToTarget: (host: String, port: Int) -> Boolean, - private val onConnectionFailed: (Throwable) -> Unit, - private val onQuickConnectedChanged: (Boolean) -> Unit, - private val onBlackListHost: (String) -> Unit, - private val setActiveDeviceActionId: (String?) -> Unit, -) { - fun onDeviceAction(device: DeviceShortcut) { - val host = device.host - val port = device.port - val connected = isConnectedToTarget(host, port) - - if (!connected) { - runAdbConnect( - "连接 ADB", - { setActiveDeviceActionId(device.id) }, - { setActiveDeviceActionId(null) }, - ) { - disconnectCurrentTargetBeforeConnecting(host, port) - try { - connectWithTimeout(host, port) - handleAdbConnected( - host, - port, - device.startScrcpyOnConnect, - device.startScrcpyOnConnect && device.openFullscreenOnStart, - device.scrcpyProfileId, - ) - onQuickConnectedChanged(false) - } catch (error: Exception) { - onConnectionFailed(error) - } - } - return - } - - runAdbConnect( - "断开 ADB", - { setActiveDeviceActionId(device.id) }, - { setActiveDeviceActionId(null) }, - ) { - onBlackListHost(host) - disconnectAdbConnection( - ConnectionTarget(host, port), - "ADB 已断开: ${device.name}", - "ADB 已断开", - ) - } - } - - fun onQuickConnect(target: ConnectionTarget) { - runAdbConnect( - "连接 ADB", - { setActiveDeviceActionId(target.toString()) }, - { setActiveDeviceActionId(null) }, - ) { - disconnectCurrentTargetBeforeConnecting(target.host, target.port) - try { - connectWithTimeout(target.host, target.port) - handleAdbConnected( - target.host, - target.port, - false, - false, - ScrcpyOptions.GLOBAL_PROFILE_ID, - ) - onQuickConnectedChanged(true) - } catch (error: Exception) { - onConnectionFailed(error) - } - } - } - - fun onDisconnectCurrent(target: ConnectionTarget?) { - runAdbConnect( - "断开 ADB", - null, - null, - ) { - target?.let { - onBlackListHost(it.host) - disconnectAdbConnection( - it, - "ADB 已断开", - "ADB 已断开", - ) - } - } - } - - fun onPair(host: String, port: String, code: String) { - runBusy("执行配对", null) { - val resolvedHost = host.trim() - val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy - val resolvedCode = code.trim() - pairTarget(resolvedHost, resolvedPort, resolvedCode) - } - } - - suspend fun onDiscoverPairingTarget(): Pair? { - return discoverPairingTarget() - } -} 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 2caa50c..6e2d997 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 @@ -1,7 +1,5 @@ package io.github.miuzarte.scrcpyforandroid.pages -import android.annotation.SuppressLint -import android.util.Log import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -31,7 +29,6 @@ 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 @@ -43,32 +40,22 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.LocalLifecycleOwner -import io.github.miuzarte.scrcpyforandroid.StreamActivity +import androidx.lifecycle.viewmodel.compose.viewModel import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget 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.ClientOptions -import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn import io.github.miuzarte.scrcpyforandroid.services.ConnectionController import io.github.miuzarte.scrcpyforandroid.services.ConnectionStateStore import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbAutoReconnectManager import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbConnectionCoordinator -import io.github.miuzarte.scrcpyforandroid.services.DisconnectCause import io.github.miuzarte.scrcpyforandroid.services.EventLogger -import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController -import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions -import io.github.miuzarte.scrcpyforandroid.storage.Settings -import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings -import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices -import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions -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.contextClick @@ -78,21 +65,16 @@ import io.github.miuzarte.scrcpyforandroid.widgets.AppListEntry import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard -import io.github.miuzarte.scrcpyforandroid.widgets.PopupMenuItem import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction -import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.DropdownImpl import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.ListPopupColumn @@ -109,14 +91,9 @@ import top.yukonga.miuix.kmp.icon.extended.More import top.yukonga.miuix.kmp.overlay.OverlayListPopup import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme -private const val ADB_CONNECT_TIMEOUT_MS = 12_000L -private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L -private const val ADB_KEEPALIVE_TIMEOUT_MS = 1_500L -private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 2_000L -private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 2_000L -private const val ADB_TCP_PROBE_TIMEOUT_MS = 500 private const val PREVIEW_CARD_ITEM_KEY = "preview_card" private const val PREVIEW_CARD_ITEM_INDEX = 3 +private val DEVICE_TWO_PANE_CONFIG_MAX_WIDTH = 640.dp internal data class DeviceConnectionServices( val adbCoordinator: DeviceAdbConnectionCoordinator, @@ -127,14 +104,15 @@ internal data class DeviceConnectionServices( @Composable internal fun DeviceTabScreen( + viewModelFactory: ViewModelProvider.Factory, scrollBehavior: ScrollBehavior, - scrcpy: Scrcpy, - connectionServices: DeviceConnectionServices, bottomInnerPadding: Dp, onOpenReorderDevices: () -> Unit, onPreviewGestureLockChanged: (Boolean) -> Unit = {}, onOpenFullscreenCompat: () -> Unit = {}, ) { + val viewModel: DeviceTabViewModel = viewModel(factory = viewModelFactory) + val navigator = LocalRootNavigator.current var showThreePointMenu by rememberSaveable { mutableStateOf(false) } var useCompactTopAppBar by remember { mutableStateOf(false) } @@ -144,18 +122,6 @@ internal fun DeviceTabScreen( val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current) val blurActive = blurBackdrop != null - fun setTopAppBarCompact(compact: Boolean) { - useCompactTopAppBar = compact - } - - fun setTwoPaneSideAction( - visible: Boolean, - configOnLeft: Boolean, - ) { - showTwoPaneSideAction = visible - configPanelOnLeft = configOnLeft - } - Scaffold( topBar = { BlurredBar(backdrop = blurBackdrop) { @@ -182,7 +148,7 @@ internal fun DeviceTabScreen( ) { Icon( imageVector = MiuixIcons.More, - contentDescription = "更多" + contentDescription = "更多", ) } OverlayListPopup( @@ -192,27 +158,30 @@ internal fun DeviceTabScreen( onDismissRequest = { showThreePointMenu = false }, ) { ListPopupColumn { - PopupMenuItem( + DropdownImpl( text = "快速设备排序", optionSize = 3, + isSelected = false, index = 0, onSelectedIndexChange = { onOpenReorderDevices() showThreePointMenu = false }, ) - PopupMenuItem( + DropdownImpl( text = "虚拟按钮排序", optionSize = 3, + isSelected = false, index = 1, onSelectedIndexChange = { navigator.push(RootScreen.VirtualButtonOrder) showThreePointMenu = false }, ) - PopupMenuItem( + DropdownImpl( text = "清空日志", optionSize = 3, + isSelected = false, index = 2, enabled = EventLogger.hasLogs(), onSelectedIndexChange = { @@ -224,35 +193,34 @@ internal fun DeviceTabScreen( } } } - if (useCompactTopAppBar) { - SmallTopAppBar( - title = "设备", - color = topAppBarColor, - actions = topAppBarActions, - ) - } else { - TopAppBar( - title = "设备", - color = topAppBarColor, - actions = topAppBarActions, - scrollBehavior = scrollBehavior, - ) - } + if (useCompactTopAppBar) SmallTopAppBar( + title = "设备", + color = topAppBarColor, + actions = topAppBarActions + ) + else TopAppBar( + title = "设备", + color = topAppBarColor, + actions = topAppBarActions, + scrollBehavior = scrollBehavior + ) } }, ) { pagePadding -> Box(modifier = if (blurActive) Modifier.layerBackdrop(blurBackdrop) else Modifier) { DeviceTabPage( + viewModel = viewModel, contentPadding = pagePadding, scrollBehavior = scrollBehavior, - scrcpy = scrcpy, - connectionServices = connectionServices, bottomInnerPadding = bottomInnerPadding, twoPaneSideToggleRequest = twoPaneSideToggleRequest, onPreviewGestureLockChanged = onPreviewGestureLockChanged, onOpenFullscreenCompat = onOpenFullscreenCompat, - onCompactTopAppBarChanged = ::setTopAppBarCompact, - onTwoPaneSideActionChanged = ::setTwoPaneSideAction, + onCompactTopAppBarChanged = { useCompactTopAppBar = it }, + onTwoPaneSideActionChanged = { visible, onLeft -> + showTwoPaneSideAction = visible + configPanelOnLeft = onLeft + }, ) } } @@ -261,10 +229,9 @@ internal fun DeviceTabScreen( @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable internal fun DeviceTabPage( + viewModel: DeviceTabViewModel, contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, - scrcpy: Scrcpy, - connectionServices: DeviceConnectionServices, bottomInnerPadding: Dp, twoPaneSideToggleRequest: Int = 0, onPreviewGestureLockChanged: (Boolean) -> Unit = {}, @@ -272,34 +239,65 @@ internal fun DeviceTabPage( onCompactTopAppBarChanged: (Boolean) -> Unit = {}, onTwoPaneSideActionChanged: (Boolean, Boolean) -> Unit = { _, _ -> }, ) { + val asBundle by viewModel.asBundle.collectAsState() + val connectionState by viewModel.connectionState.collectAsState() + val sessionInfo by viewModel.sessionInfo.collectAsState() + val listingsRefreshBusy by viewModel.listingsRefreshBusy.collectAsState() + val listingsRefreshVersion by viewModel.listingsRefreshVersion.collectAsState() + val busy by viewModel.busy.collectAsState() + val adbConnecting by viewModel.adbConnecting.collectAsState() + val editingDeviceId by viewModel.editingDeviceId.collectAsState() + val activeDeviceActionId by viewModel.activeDeviceActionId.collectAsState() + val showRecentTasksSheet by viewModel.showRecentTasksSheet.collectAsState() + val showAllAppsSheet by viewModel.showAllAppsSheet.collectAsState() + val imeRequestToken by viewModel.imeRequestToken.collectAsState() + val pendingScrollToPreview by viewModel.pendingScrollToPreview.collectAsState() + val savedShortcuts by viewModel.savedShortcuts.collectAsState() + val quickConnectInputTemp by viewModel.quickConnectInput.collectAsState() + + val adbConnected by viewModel.adbConnected.collectAsState() + val statusLine by viewModel.statusLine.collectAsState() + val isQuickConnected by viewModel.isQuickConnected.collectAsState() + val currentTarget by viewModel.currentTarget.collectAsState() + val connectedDeviceLabel by viewModel.connectedDeviceLabel.collectAsState() + val connectedScrcpyProfileId by viewModel.connectedScrcpyProfileId.collectAsState() + val connectedScrcpyBundle by viewModel.connectedScrcpyBundle.collectAsState() + val connectedScrcpyProfileName by viewModel.connectedScrcpyProfileName.collectAsState() + val canShowPreviewControls by viewModel.canShowPreviewControls.collectAsState() + val virtualButtonLayout by viewModel.virtualButtonLayout.collectAsState() + val activity = LocalActivity.current - val windowSizeClass = activity?.let { calculateWindowSizeClass(it) } val context = LocalContext.current - val scope = rememberCoroutineScope() - val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) } - val adbCoordinator = connectionServices.adbCoordinator - val connectionStateStore = connectionServices.connectionStateStore - val connectionController = connectionServices.connectionController - val autoReconnectManager = connectionServices.autoReconnectManager - val lifecycleOwner = LocalLifecycleOwner.current - val haptic = LocalHapticFeedback.current val navigator = LocalRootNavigator.current val snackbar = LocalSnackbarController.current + val lifecycleOwner = LocalLifecycleOwner.current + + val apps = remember(listingsRefreshVersion) { viewModel.scrcpyListings.apps } + val recentTasks = remember(listingsRefreshVersion) { viewModel.scrcpyListings.recentTasks } - val asBundleShared by appSettings.bundleState.collectAsState() - val asBundleSharedLatest by rememberUpdatedState(asBundleShared) - var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } - val asBundleLatest by rememberUpdatedState(asBundle) var handledTwoPaneSideToggleRequest by rememberSaveable { mutableIntStateOf(twoPaneSideToggleRequest) } - LaunchedEffect(asBundleShared) { - if (asBundle != asBundleShared) { - asBundle = asBundleShared - } + + val listState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + val isPreviewCardVisible by remember(listState) { + derivedStateOf { listState.layoutInfo.visibleItemsInfo.any { it.key == PREVIEW_CARD_ITEM_KEY } } } + + LaunchedEffect(Unit) { viewModel.startKeepAliveLoop() } + LaunchedEffect(Unit) { viewModel.startAutoReconnectLoop() } + LaunchedEffect(Unit) { viewModel.startProfileIdSync() } + LaunchedEffect(Unit) { viewModel.startRecentTasksAutoRefresh() } + + fun openFullscreenControl() { + if (viewModel.shouldOpenFullscreenCompat()) + onOpenFullscreenCompat() + else + viewModel.openStreamActivity(context) + } + DisposableEffect(Unit) { onDispose { onPreviewGestureLockChanged(false) @@ -307,530 +305,28 @@ internal fun DeviceTabPage( onTwoPaneSideActionChanged(false, true) } } - LaunchedEffect(asBundle) { - delay(Settings.BUNDLE_SAVE_DELAY) - if (asBundle != asBundleSharedLatest) { - appSettings.saveBundle(asBundle) - } - } - DisposableEffect(Unit) { - onDispose { - taskScope.launch { - appSettings.saveBundle(asBundleLatest) - } - } - } - - val qdBundleShared by quickDevices.bundleState.collectAsState() - val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared) - var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) } - val qdBundleLatest by rememberUpdatedState(qdBundle) - LaunchedEffect(qdBundleShared) { - if (qdBundle != qdBundleShared) { - qdBundle = qdBundleShared - } - } - LaunchedEffect(qdBundle) { - delay(Settings.BUNDLE_SAVE_DELAY) - if (qdBundle != qdBundleSharedLatest) { - quickDevices.saveBundle(qdBundle) - } - } - DisposableEffect(Unit) { - onDispose { - taskScope.launch { - quickDevices.saveBundle(qdBundleLatest) - } - } - } - - // read only - val soBundleShared by scrcpyOptions.bundleState.collectAsState() - val scrcpyProfilesState by scrcpyProfiles.state.collectAsState() - - var isAppInForeground by rememberSaveable { mutableStateOf(true) } DisposableEffect(lifecycleOwner) { - isAppInForeground = + viewModel.setAppInForeground( lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + ) val observer = LifecycleEventObserver { _, event -> when (event) { - Lifecycle.Event.ON_START -> isAppInForeground = true - Lifecycle.Event.ON_STOP -> isAppInForeground = false + Lifecycle.Event.ON_START -> viewModel.setAppInForeground(true) + Lifecycle.Event.ON_STOP -> viewModel.setAppInForeground(false) else -> Unit } } lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - // Run adb operations on a dedicated single thread. - // Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic. - - var busy by rememberSaveable { mutableStateOf(false) } - val connectionState by connectionStateStore.state.collectAsState() - val adbSession = connectionState.adbSession - 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 = rememberSaveable(saver = LazyListState.Saver) { - LazyListState() - } - val isPreviewCardVisible by remember(listState) { - derivedStateOf { - listState.layoutInfo.visibleItemsInfo.any { it.key == PREVIEW_CARD_ITEM_KEY } - } + LaunchedEffect(Unit) { + viewModel.snackbarEvents.collect { message -> snackbar.show(message) } } - var savedShortcuts by remember { - mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)) - } - LaunchedEffect(qdBundle.quickDevicesList) { - savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList) - } - // save changes when [savedShortcuts] was modified - LaunchedEffect(savedShortcuts) { - val serialized = savedShortcuts.marshalToString() - if (serialized != qdBundle.quickDevicesList) { - qdBundle = qdBundle.copy(quickDevicesList = serialized) - } - } - - fun resolveScrcpyBundle(profileId: String): ScrcpyOptions.Bundle { - if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) { - return soBundleShared - } - return scrcpyProfilesState.profiles.firstOrNull { it.id == profileId }?.bundle - ?: soBundleShared - } - - val adbConnected = adbSession.isConnected - val statusLine = adbSession.statusLine - val isQuickConnected = adbSession.isQuickConnected - val currentTarget = adbSession.currentTarget - val connectedDeviceLabel = adbSession.connectedDeviceLabel - val connectedScrcpyProfileId = - if (adbConnected && currentTarget != null) - savedShortcuts.get(currentTarget.host, currentTarget.port) - ?.scrcpyProfileId - ?: adbSession.connectedScrcpyProfileId - else - adbSession.connectedScrcpyProfileId - - val connectedScrcpyBundle = resolveScrcpyBundle(connectedScrcpyProfileId) - val connectedVideoPlaybackEnabled = - connectedScrcpyBundle.video && connectedScrcpyBundle.videoPlayback - val connectedScrcpyProfileName = remember(connectedScrcpyProfileId, scrcpyProfilesState) { - scrcpyProfilesState.profiles - .firstOrNull { it.id == connectedScrcpyProfileId } - ?.name - ?: ScrcpyOptions.GLOBAL_PROFILE_NAME - } - val apps = remember(listingsRefreshVersion) { scrcpy.listings.apps } - val recentTasks = remember(listingsRefreshVersion) { scrcpy.listings.recentTasks } - - val sessionReconnectBlacklistHosts = remember { mutableSetOf() } - - val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) { - VirtualButtonActions.splitLayout( - VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout) - ) - } - - 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 = scrcpy, - text = text, - keyInjectMode = scrcpyOptions.toClientOptions(connectedScrcpyBundle).keyInjectMode, - ) { error, useClipboardPaste -> - logEvent("输入法文本提交失败: ${error.message}", Log.WARN, error) - snackbar.show( - if (useClipboardPaste) "非 ASCII 文本粘贴失败" - else "文本输入失败" - ) - } - } - - var quickConnectInputTemp by rememberSaveable(qdBundle.quickConnectInput) { - mutableStateOf(qdBundle.quickConnectInput) - } - - /** - * Disconnect the current ADB connection and stop any running scrcpy session. - * - * Concurrency / thread boundary: - * - Native calls that may block are executed on ADB dispatcher. - * - This ensures UI coroutines are never blocked by synchronous native I/O. - * - * Side effects: - * - Calls `scrcpy.stop()` and `NativeAdbService.disconnect()` (best-effort). - * - Resets UI-visible connection state: `adbConnected`, `currentTargetHost/Port`, - * `sessionInfo`, device capability flags, `statusLine`, and `connectedDeviceLabel`. - * - Updates the saved quick-device list via [savedShortcuts.update] when a target is provided. - * - Logs an optional [logMessage] to the local event log. - * - Shows an optional snackbar message asynchronously (launched on the composition scope) - * so callers don't get blocked by `snack.showSnackbar` (it is suspending). - * - * Usage notes: - * - Prefer calling this from UI code wrapped by `runAdbConnect`/`runBusy` where appropriate - * so the UI busy/connect gates are respected. - * - This function is idempotent from the UI state perspective: calling it when already - * disconnected will simply reset UI fields and not throw. - */ - suspend fun disconnectAdbConnection( - clearQuickOnlineForTarget: ConnectionTarget? = currentTarget, - logMessage: String? = null, - showSnackMessage: String? = null, - cause: DisconnectCause = DisconnectCause.User, - statusLine: String = "未连接", - ) { - val result = connectionController.disconnectAdbConnection( - clearQuickOnlineForTarget = clearQuickOnlineForTarget, - cause = cause, - statusLine = statusLine, - ) - val targetToClear = result.clearedTarget - targetToClear?.let { target -> - if (target.host.isNotBlank()) - savedShortcuts = savedShortcuts.update( - host = target.host, port = target.port - ) - } - logMessage?.let { logEvent(it) } - if (!showSnackMessage.isNullOrBlank()) { - snackbar.show(showSnackMessage) - } - } - - suspend fun disconnectCurrentTargetBeforeConnecting(newHost: String, newPort: Int) { - val disconnected = connectionController.disconnectCurrentTargetBeforeConnecting( - newHost = newHost, - newPort = newPort, - ) ?: return - - sessionReconnectBlacklistHosts += disconnected.host - if (disconnected.host.isNotBlank()) { - savedShortcuts = savedShortcuts.update( - host = disconnected.host, - port = disconnected.port, - ) - } - } - - fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { - val currentBundle = resolveScrcpyBundle(connectedScrcpyProfileId) - val audioSupported = sdkInt !in 0..<30 - connectionController.applyConnectedDeviceCapabilities(sdkInt) - if (!audioSupported && currentBundle.audio) { - scope.launch { - if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) { - scrcpyOptions.updateBundle { it.copy(audio = false) } - } else { - scrcpyProfiles.updateBundle( - connectedScrcpyProfileId, - currentBundle.copy(audio = false), - ) - } - } - logEvent( - "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", - Log.WARN - ) - } - val cameraSupported = sdkInt !in 0..<31 - if (!cameraSupported && currentBundle.videoSource == "camera") { - scope.launch { - if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) { - scrcpyOptions.updateBundle { it.copy(videoSource = "display") } - } else { - scrcpyProfiles.updateBundle( - connectedScrcpyProfileId, - currentBundle.copy(videoSource = "display"), - ) - } - } - logEvent( - "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display", - Log.WARN - ) - } - } - - /** - * Attempt to connect to an adb endpoint within a short timeout. - * - * Execution: - * - Runs `nativeCore.adbConnect(host, port)` on [adbWorkerDispatcher] and wraps it with - * [withTimeout] to avoid hanging forever. Returns true on success, false / throws on failure - * depending on the underlying native behavior. - * - * Why this exists: - * - Some adb endpoints can take long to accept TCP connects; the UI should not wait - * indefinitely. Use a small, caller-chosen timeout to keep UX snappy. - */ - suspend fun connectWithTimeout(host: String, port: Int) { - connectionController.connectWithTimeout(host, port, ADB_CONNECT_TIMEOUT_MS) - } - - /** - * Execute a suspend [block] while toggling the `busy` UI gate. - * - * - Intended for non-adb user actions (UI-level operations) that should disable - * certain controls while active (e.g. scrcpy start/stop, pairing, listing). - * - Errors are logged and surfaced via a snackbar where appropriate. The snackbar - * is launched asynchronously so the outer coroutine can continue to unwind. - * - Ensures `busy` is reset in `finally` so the UI recovers even on exceptions. - */ - fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { - // For non-adb actions (start/stop/pair/list refresh...). - if (busy) return - scope.launch { - busy = true - try { - block() - } catch (_: TimeoutCancellationException) { - logEvent("$label 超时", Log.WARN) - } catch (e: IllegalArgumentException) { - val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName - logEvent("$label 参数错误: $detail", Log.WARN, e) - snackbar.show("$label 参数错误: $detail") - } catch (e: Exception) { - val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName - logEvent("$label 失败: $detail", Log.ERROR, e) - } finally { - busy = false - onFinished?.invoke() - } - } - } - - /** - * Execute a manual ADB-related suspend [block] while toggling `adbConnecting`. - * - * Purpose: - * - Called from explicit user actions (pressing "connect" / "disconnect"). - * - Keeps the UI responsive by marking only user-initiated connect operations as in-progress. - * - * Concurrency notes: - * - Background auto-reconnect attempts deliberately DO NOT set `adbConnecting` so that - * UI controls remain actionable while background retries occur. - * - Errors and timeouts are logged and surfaced similarly to `runBusy`. - */ - fun runAdbConnect( - label: String, - onStarted: (() -> Unit)? = null, - onFinished: (() -> Unit)? = null, - block: suspend () -> Unit, - ) { - // For manual adb operations from user actions. - if (adbConnecting) return - scope.launch { - onStarted?.invoke() - adbConnecting = true - try { - block() - } catch (_: TimeoutCancellationException) { - logEvent("$label 超时", Log.WARN) - } catch (e: IllegalArgumentException) { - val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName - logEvent("$label 参数错误: $detail", Log.WARN, e) - snackbar.show("$label 参数错误: $detail") - } catch (e: Exception) { - val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName - logEvent("$label 失败: $detail", Log.ERROR, e) - } finally { - adbConnecting = false - onFinished?.invoke() - } - } - } - - LaunchedEffect(adbConnected, currentTarget, isAppInForeground) { - if (!adbConnected || currentTarget == null) return@LaunchedEffect - autoReconnectManager.runKeepAliveLoop( - isForeground = { isAppInForeground }, - intervalMs = ADB_KEEPALIVE_INTERVAL_MS, - connectTimeoutMs = ADB_CONNECT_TIMEOUT_MS, - keepAliveTimeoutMs = ADB_KEEPALIVE_TIMEOUT_MS, - onReconnectSuccess = { host, port -> - logEvent("ADB 自动重连成功: $host:$port") - snackbar.show("ADB 自动重连成功") - }, - onReconnectFailure = { error -> - disconnectAdbConnection( - cause = DisconnectCause.KeepAliveFailed, - statusLine = "ADB 连接断开", - ) - logEvent("ADB 自动重连失败: $error", Log.ERROR) - snackbar.show("ADB 自动重连失败") - }, - ) - } - - LaunchedEffect(adbConnected, currentTarget?.host, currentTarget?.port, savedShortcuts) { - val target = currentTarget ?: return@LaunchedEffect - if (!adbConnected) return@LaunchedEffect - val boundProfileId = savedShortcuts - .get(target.host, target.port) - ?.scrcpyProfileId - ?: ScrcpyOptions.GLOBAL_PROFILE_ID - if (boundProfileId != adbSession.connectedScrcpyProfileId) { - connectionController.syncConnectedScrcpyProfileId(boundProfileId) - logEvent("当前连接设备已切换为配置: $boundProfileId") - } - } - - fun openFullscreenControl() { - if (asBundle.fullscreenCompatibilityMode) { - onOpenFullscreenCompat() - } else { - context.startActivity(StreamActivity.createIntent(context)) - } - } - - suspend fun startScrcpySession( - openFullscreen: Boolean = false, - startAppOverride: String? = null, - ) { - val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId) - val options = scrcpyOptions.toClientOptions(activeBundle).fix() - val resolvedOptions = startAppOverride - ?.takeIf { it.isNotBlank() } - ?.let { options.copy(startApp = it) } - ?: options - val session = scrcpy.start(resolvedOptions) - pendingScrollToPreview = resolvedOptions.video && resolvedOptions.videoPlayback - if (resolvedOptions.startApp.isNotBlank() && resolvedOptions.control) { - runCatching { - scrcpy.startApp(resolvedOptions.startApp) - }.onSuccess { - logEvent("已请求 scrcpy 启动应用: ${resolvedOptions.startApp}") - }.onFailure { error -> - logEvent( - "通过 scrcpy 控制通道启动应用失败: " + - "${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}", - Log.WARN, - error, - ) - } - } - if ((resolvedOptions.fullscreen || openFullscreen) && - resolvedOptions.video && - resolvedOptions.videoPlayback - ) withContext(Dispatchers.Main) { - openFullscreenControl() - } - if (resolvedOptions.disableScreensaver) - AppScreenOn.acquire() - - connectionController.markScrcpyStarted() - @SuppressLint("DefaultLocale") - val videoDetail = - if (!resolvedOptions.video) "off" - else if (activeBundle.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default" - else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " + - "@${String.format("%.1f", activeBundle.videoBitRate / 1_000_000f)}Mbps" - - val audioDetail = - if (!activeBundle.audio) "off" - else if (activeBundle.audioBitRate <= 0) "${resolvedOptions.audioCodec} default source=${resolvedOptions.audioSource}" - else "${resolvedOptions.audioCodec} ${activeBundle.audioBitRate / 1_000f}Kbps source=${resolvedOptions.audioSource}${if (!resolvedOptions.audioPlayback) "(no-playback)" else ""}" - - logEvent( - "scrcpy 已启动: device=${session.deviceName}" + - ", video=$videoDetail, audio=$audioDetail" + - ", control=${resolvedOptions.control}, turnScreenOff=${resolvedOptions.turnScreenOff}" + - ", maxSize=${resolvedOptions.maxSize}, maxFps=${resolvedOptions.maxFps}" - ) - snackbar.show( - "scrcpy 已启动" + - if (resolvedOptions.recordFilename.isNotBlank()) "并开始录制" - else "" - ) - } - - suspend fun stopScrcpySession() { - val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId) - val options = scrcpyOptions.toClientOptions(activeBundle).fix() - if (options.killAdbOnClose) { - currentTarget?.host?.let { sessionReconnectBlacklistHosts += it } - val stopResult = connectionController.stopScrcpySession(killAdbOnClose = true) - stopResult.clearedTarget?.let { target -> - if (target.host.isNotBlank()) { - savedShortcuts = savedShortcuts.update( - host = target.host, - port = target.port, - ) - } - } - logEvent("scrcpy 已停止,ADB 已断开") - snackbar.show("scrcpy 已停止,ADB 已断开") - } else { - connectionController.stopScrcpySession(killAdbOnClose = false) - logEvent("scrcpy 已停止") - snackbar.show("scrcpy 已停止") - } + LaunchedEffect(Unit) { + viewModel.fullscreenRequests.collect { openFullscreenControl() } } LaunchedEffect(pendingScrollToPreview, isPreviewCardVisible) { @@ -839,217 +335,35 @@ internal fun DeviceTabPage( listState.animateScrollToItem(PREVIEW_CARD_ITEM_INDEX) } - suspend fun handleAdbConnected( - host: String, port: Int, - autoStartScrcpy: Boolean = false, - autoEnterFullScreen: Boolean = false, - scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID, - ) { - val connected = connectionController.handleAdbConnected( - host = host, - port = port, - scrcpyProfileId = scrcpyProfileId, - ) - val target = connected.target - val info = connected.info - val fullLabel = if (info.serial.isNotBlank()) { - "${info.model} (${info.serial})" - } else { - info.model - } - - applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease) - savedShortcuts = savedShortcuts.update( - host = host, port = port, - name = fullLabel, - updateNameOnlyWhenEmpty = true - ) - - logEvent( - "ADB 已连接: " + - "model=${info.model}, " + - "serial=${info.serial.ifBlank { "unknown" }}, " + - "manufacturer=${info.manufacturer.ifBlank { "unknown" }}, " + - "brand=${info.brand.ifBlank { "unknown" }}, " + - "device=${info.device.ifBlank { "unknown" }}, " + - "android=${info.androidRelease.ifBlank { "unknown" }}, " + - "sdk=${info.sdkInt}" - ) - snackbar.show("ADB 已连接") - if (asBundle.adbAutoLoadAppListOnConnect) { - scope.launch(Dispatchers.IO) { - runCatching { - scrcpy.listings.getApps(forceRefresh = true) - }.onFailure { error -> - logEvent("获取应用列表失败: ${error.message}", Log.WARN, error) - } - } - } - - if (autoStartScrcpy && sessionInfo == null) runBusy("启动 scrcpy") { - startScrcpySession(openFullscreen = autoStartScrcpy && autoEnterFullScreen) - } - } - - LaunchedEffect( - adbConnected, - currentTarget?.host, - currentTarget?.port, - isAppInForeground, - ) { - if (!adbConnected || !isAppInForeground) return@LaunchedEffect - withContext(Dispatchers.IO) { - runCatching { - scrcpy.listings.getRecentTasks(forceRefresh = true) - }.onFailure { error -> - logEvent("获取最近任务失败: ${error.message}", Log.WARN, error) - } - } - } - - LaunchedEffect( - adbConnected, - asBundle.adbAutoReconnectPairedDevice, - asBundle.adbMdnsLanDiscovery, - isAppInForeground, - ) { - if (adbConnected || !asBundle.adbAutoReconnectPairedDevice) return@LaunchedEffect - autoReconnectManager.runAutoReconnectLoop( - isForeground = { isAppInForeground }, - isAutoReconnectEnabled = { asBundle.adbAutoReconnectPairedDevice }, - isBusy = { busy }, - isAdbConnecting = { adbConnecting }, - hasActiveSession = { sessionInfo != null }, - savedShortcuts = { savedShortcuts.toList() }, - isBlacklisted = { host -> sessionReconnectBlacklistHosts.contains(host) }, - connectTimeoutMs = ADB_CONNECT_TIMEOUT_MS, - probeTimeoutMs = ADB_TCP_PROBE_TIMEOUT_MS, - discoverConnectService = { - adbCoordinator.discoverConnectService( - timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS, - includeLanDevices = asBundle.adbMdnsLanDiscovery, - ) - }, - onMdnsPortChanged = { host, oldPort, newPort -> - savedShortcuts = savedShortcuts.update( - host = host, - port = oldPort, - newPort = newPort, - ) - logEvent("mDNS 发现新端口,已更新快速设备: $host:$oldPort -> $host:$newPort") - }, - onKnownDeviceReconnected = { target -> - savedShortcuts = savedShortcuts.update( - host = target.host, - port = target.port, - ) - logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}") - }, - onDiscoveredDeviceReconnected = { host, port, _ -> - savedShortcuts = savedShortcuts.update(host = host, port = port) - logEvent("ADB 自动重连成功: $host:$port") - }, - retryIntervalMs = ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS, - ) - } - - val adbCallbacks = DeviceTabAdbCallbacks( - runAdbConnect = { label, onStarted, onFinished, block -> - runAdbConnect(label, onStarted, onFinished, block) - }, - runBusy = { label, onFinished, block -> - runBusy(label, onFinished, block) - }, - disconnectCurrentTargetBeforeConnecting = { host, port -> - disconnectCurrentTargetBeforeConnecting(host, port) - }, - connectWithTimeout = { host, port -> - connectWithTimeout(host, port) - }, - handleAdbConnected = { host, port, autoStartScrcpy, autoEnterFullScreen, scrcpyProfileId -> - handleAdbConnected( - host = host, - port = port, - autoStartScrcpy = autoStartScrcpy, - autoEnterFullScreen = autoEnterFullScreen, - scrcpyProfileId = scrcpyProfileId, - ) - }, - disconnectAdbConnection = { clearQuickOnlineForTarget, logMessage, showSnackMessage -> - disconnectAdbConnection( - clearQuickOnlineForTarget = clearQuickOnlineForTarget, - logMessage = logMessage, - showSnackMessage = showSnackMessage, - ) - }, - discoverPairingTarget = { - adbCoordinator.discoverPairingService( - includeLanDevices = asBundle.adbMdnsLanDiscovery, - ) - }, - pairTarget = { host, port, code -> - val ok = adbCoordinator.pair(host, port, code) - logEvent( - if (ok) "配对成功" else "配对失败", - if (ok) Log.INFO else Log.ERROR - ) - snackbar.show(if (ok) "配对成功" else "配对失败") - ok - }, - isConnectedToTarget = { host, port -> - adbConnected && currentTarget?.host == host && currentTarget.port == port - }, - onConnectionFailed = { error -> - connectionController.markConnectionFailed(error) - logEvent("ADB 连接失败: $error", Log.ERROR) - snackbar.show("ADB 连接失败") - }, - onQuickConnectedChanged = { quickConnected -> - connectionController.updateQuickConnected(quickConnected) - }, - onBlackListHost = { host -> sessionReconnectBlacklistHosts += host }, - setActiveDeviceActionId = { activeDeviceActionId = it }, - ) - - val canShowPreviewControls = - adbConnected && - connectedVideoPlaybackEnabled && - sessionInfo != null && - sessionInfo!!.width > 0 && - sessionInfo!!.height > 0 - fun handleVirtualButtonAction(action: VirtualButtonAction) { when (action) { VirtualButtonAction.RECENT_TASKS -> { - showRecentTasksSheet = true + viewModel.showRecentTasks() if (recentTasks.isEmpty() && !listingsRefreshBusy) { - scope.launch(Dispatchers.IO) { refreshApps() } - scope.launch(Dispatchers.IO) { refreshRecentTasks() } + scope.launch(Dispatchers.IO) { + viewModel.refreshApps() + } + scope.launch(Dispatchers.IO) { + viewModel.refreshRecentTasks() + } } } VirtualButtonAction.ALL_APPS -> { - showAllAppsSheet = true + viewModel.showAllApps() if (apps.isEmpty() && !listingsRefreshBusy) { - scope.launch(Dispatchers.IO) { refreshApps() } + scope.launch(Dispatchers.IO) { + viewModel.refreshApps() + } } } - VirtualButtonAction.TOGGLE_IME -> { - imeRequestToken++ + VirtualButtonAction.TOGGLE_IME -> viewModel.toggleIme() + VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> scope.launch { + viewModel.pasteLocalClipboard(context) } - VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> { - scope.launch { pasteLocalClipboard() } - } - - else -> { - val keycode = action.keycode ?: return - runBusy("发送 ${action.title}") { - scrcpy.injectKeycode(0, keycode) - scrcpy.injectKeycode(1, keycode) - } - } + else -> viewModel.handleVirtualButtonAction(action) } } @@ -1072,12 +386,10 @@ internal fun DeviceTabPage( isConnected = { device -> adbConnected && currentTarget?.host == device.host - && currentTarget.port == device.port + && currentTarget?.port == device.port }, actionEnabled = !busy && !adbConnecting, - actionInProgress = { device -> - adbConnecting && activeDeviceActionId == device.id - }, + actionInProgress = { device -> adbConnecting && activeDeviceActionId == device.id }, editingDeviceId = editingDeviceId, onClick = { device -> if (editingDeviceId != device.id) @@ -1086,22 +398,23 @@ internal fun DeviceTabPage( onLongClick = { device -> val connected = adbConnected && currentTarget?.host == device.host - && currentTarget.port == device.port + && currentTarget?.port == device.port if (connected) { snackbar.show("无法修改已连接的设备") } else { - editingDeviceId = + viewModel.setEditingDeviceId( if (editingDeviceId != device.id) device.id else null + ) } }, onAction = { device -> haptic.contextClick() - if (editingDeviceId == device.id) editingDeviceId = null - adbCallbacks.onDeviceAction(device) + if (editingDeviceId == device.id) viewModel.setEditingDeviceId(null) + viewModel.onDeviceAction(device) }, onEditorSave = { device, updated -> - savedShortcuts = savedShortcuts.update( + viewModel.updateShortcut( id = device.id, name = updated.name, host = updated.host, @@ -1112,10 +425,10 @@ internal fun DeviceTabPage( ) }, onEditorDelete = { device -> - savedShortcuts = savedShortcuts.remove(id = device.id) - editingDeviceId = null + viewModel.removeShortcut(device.id) + viewModel.setEditingDeviceId(null) }, - onEditorCancel = { editingDeviceId = null }, + onEditorCancel = { viewModel.setEditingDeviceId(null) }, ) } @@ -1123,30 +436,19 @@ internal fun DeviceTabPage( fun QuickConnectSection() { QuickConnectCard( input = quickConnectInputTemp, - onValueChange = { quickConnectInputTemp = it }, - onFocusLost = { - qdBundle = qdBundle.copy(quickConnectInput = quickConnectInputTemp) - }, + onValueChange = { viewModel.setQuickConnectInput(it) }, + onFocusLost = { viewModel.saveQuickConnectInput() }, enabled = !adbConnecting, onAddDevice = { val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) ?: return@QuickConnectCard - - savedShortcuts = savedShortcuts.upsert( - DeviceShortcut(host = target.host, port = target.port) - ) - - Log.d( - "SavedShortcuts", - "size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}" - ) + viewModel.upsertShortcut(DeviceShortcut(host = target.host, port = target.port)) snackbar.show("已添加设备: ${target.host}:${target.port}") }, onConnect = { val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp) ?: return@QuickConnectCard - - adbCallbacks.onQuickConnect(target) + viewModel.onQuickConnect(target) }, ) } @@ -1157,8 +459,8 @@ internal fun DeviceTabPage( PairingCard( busy = busy, autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen, - onDiscoverTarget = adbCallbacks::onDiscoverPairingTarget, - onPair = adbCallbacks::onPair, + onDiscoverTarget = { viewModel.onDiscoverPairingTarget() }, + onPair = viewModel::onPair, ) } @@ -1170,8 +472,8 @@ internal fun DeviceTabPage( activeProfileId = connectedScrcpyProfileId, activeBundle = connectedScrcpyBundle, hideSimpleConfigItems = asBundle.hideSimpleConfigItems, - audioForwardingSupported = adbSession.audioForwardingSupported, - cameraMirroringSupported = adbSession.cameraMirroringSupported, + audioForwardingSupported = connectionState.adbSession.audioForwardingSupported, + cameraMirroringSupported = connectionState.adbSession.cameraMirroringSupported, adbConnecting = adbConnecting, isQuickConnected = isQuickConnected, advancedEndActionText = connectedScrcpyProfileName, @@ -1181,10 +483,11 @@ internal fun DeviceTabPage( else -> "空" }, onOpenAllApps = { - showAllAppsSheet = true - if (apps.isEmpty() && !listingsRefreshBusy) { - scope.launch(Dispatchers.IO) { refreshApps() } - } + viewModel.showAllApps() + if (apps.isEmpty() && !listingsRefreshBusy) + scope.launch(Dispatchers.IO) { + viewModel.refreshApps() + } }, recentTasksEndActionText = when { listingsRefreshBusy -> "..." @@ -1192,35 +495,25 @@ internal fun DeviceTabPage( else -> "空" }, onOpenRecentTasks = { - showRecentTasksSheet = true - if (recentTasks.isEmpty() && !listingsRefreshBusy) { - scope.launch(Dispatchers.IO) { refreshRecentTasks() } - } + viewModel.showRecentTasks() + if (recentTasks.isEmpty() && !listingsRefreshBusy) + scope.launch(Dispatchers.IO) { + viewModel.refreshRecentTasks() + } }, onOpenAdvanced = { navigator.push(RootScreen.Advanced) }, onStartStopHaptic = haptic::contextClick, - onStart = { - runBusy("启动 scrcpy") { - startScrcpySession() - } - }, - onStop = { - runBusy("停止 scrcpy") { - stopScrcpySession() - } - }, + onStart = viewModel::startScrcpy, + onStop = viewModel::stopScrcpy, sessionInfo = sessionInfo, - onDisconnect = { adbCallbacks.onDisconnectCurrent(currentTarget) }, + onDisconnect = { viewModel.onDisconnectCurrent(currentTarget) }, showFullscreenAction = false, onOpenFullscreen = ::openFullscreenControl, ) } @Composable - fun PreviewSection( - modifier: Modifier = Modifier, - directControlEnabled: Boolean = false, - ) { + fun PreviewSection(modifier: Modifier = Modifier, directControlEnabled: Boolean = false) { PreviewCard( modifier = modifier, sessionInfo = sessionInfo, @@ -1231,7 +524,7 @@ internal fun DeviceTabPage( val session = sessionInfo if (session != null) { withContext(Dispatchers.IO) { - scrcpy.injectTouch( + viewModel.injectTouch( action = action, pointerId = pointerId, x = x, @@ -1247,21 +540,25 @@ internal fun DeviceTabPage( }, onBackOrScreenOn = { action -> withContext(Dispatchers.IO) { - scrcpy.pressBackOrTurnScreenOn(action) + viewModel.pressBackOrTurnScreenOn(action) } }, imeRequestToken = imeRequestToken, - onImeCommitText = { text -> commitImeText(text) }, + onImeCommitText = { text -> + scope.launch { + viewModel.commitImeText(text) + } + }, onImeDeleteSurroundingText = { beforeLength, _ -> submitImeDeleteSurroundingText( - scrcpy = scrcpy, + scrcpy = viewModel.scrcpy, beforeLength = beforeLength, afterLength = 0, ) }, onImeKeyEvent = { event -> submitImeKeyEvent( - scrcpy = scrcpy, + scrcpy = viewModel.scrcpy, event = event, keyInjectMode = sessionInfo?.keyInjectMode ?: ClientOptions.KeyInjectMode.MIXED, @@ -1269,7 +566,7 @@ internal fun DeviceTabPage( ) }, autoBringIntoView = pendingScrollToPreview && !directControlEnabled, - onAutoBringIntoViewConsumed = { pendingScrollToPreview = false }, + onAutoBringIntoViewConsumed = { viewModel.clearPendingScrollToPreview() }, onTouchActiveChanged = { if (directControlEnabled) onPreviewGestureLockChanged(it) }, @@ -1284,8 +581,8 @@ internal fun DeviceTabPage( activeProfileId = connectedScrcpyProfileId, activeBundle = connectedScrcpyBundle, hideSimpleConfigItems = asBundle.hideSimpleConfigItems, - audioForwardingSupported = adbSession.audioForwardingSupported, - cameraMirroringSupported = adbSession.cameraMirroringSupported, + audioForwardingSupported = connectionState.adbSession.audioForwardingSupported, + cameraMirroringSupported = connectionState.adbSession.cameraMirroringSupported, adbConnecting = adbConnecting, isQuickConnected = isQuickConnected, advancedEndActionText = connectedScrcpyProfileName, @@ -1295,10 +592,11 @@ internal fun DeviceTabPage( else -> "空" }, onOpenAllApps = { - showAllAppsSheet = true - if (apps.isEmpty() && !listingsRefreshBusy) { - scope.launch(Dispatchers.IO) { refreshApps() } - } + viewModel.showAllApps() + if (apps.isEmpty() && !listingsRefreshBusy) + scope.launch(Dispatchers.IO) { + viewModel.refreshApps() + } }, recentTasksEndActionText = when { listingsRefreshBusy -> "..." @@ -1306,25 +604,18 @@ internal fun DeviceTabPage( else -> "空" }, onOpenRecentTasks = { - showRecentTasksSheet = true - if (recentTasks.isEmpty() && !listingsRefreshBusy) { - scope.launch(Dispatchers.IO) { refreshRecentTasks() } - } + viewModel.showRecentTasks() + if (recentTasks.isEmpty() && !listingsRefreshBusy) + scope.launch(Dispatchers.IO) { + viewModel.refreshRecentTasks() + } }, onOpenAdvanced = { navigator.push(RootScreen.Advanced) }, onStartStopHaptic = haptic::contextClick, - onStart = { - runBusy("启动 scrcpy") { - startScrcpySession() - } - }, - onStop = { - runBusy("停止 scrcpy") { - stopScrcpySession() - } - }, + onStart = viewModel::startScrcpy, + onStop = viewModel::stopScrcpy, sessionInfo = sessionInfo, - onDisconnect = { adbCallbacks.onDisconnectCurrent(currentTarget) }, + onDisconnect = { viewModel.onDisconnectCurrent(currentTarget) }, showFullscreenAction = canShowPreviewControls, onOpenFullscreen = ::openFullscreenControl, reverseSideActions = asBundle.deviceTwoPaneConfigOnRight, @@ -1406,6 +697,7 @@ internal fun DeviceTabPage( } BoxWithConstraints(Modifier.fillMaxSize()) { + val windowSizeClass = activity?.let { calculateWindowSizeClass(it) } val useTwoPane = maxWidth > maxHeight || when (windowSizeClass?.widthSizeClass) { WindowWidthSizeClass.Compact -> false @@ -1431,16 +723,11 @@ internal fun DeviceTabPage( if (twoPaneSideToggleRequest == handledTwoPaneSideToggleRequest) return@LaunchedEffect handledTwoPaneSideToggleRequest = twoPaneSideToggleRequest if (!showTwoPaneSideAction) return@LaunchedEffect - asBundle = asBundleLatest.copy( - deviceTwoPaneConfigOnRight = !asBundleLatest.deviceTwoPaneConfigOnRight - ) + viewModel.updateAsBundle { it.copy(deviceTwoPaneConfigOnRight = !it.deviceTwoPaneConfigOnRight) } } if (!useTwoPane || !canShowPreviewControls) { - DeviceListContent( - state = listState, - includeInlinePreviewControls = true, - ) + DeviceListContent(state = listState, includeInlinePreviewControls = true) } else { Row( modifier = Modifier @@ -1448,7 +735,7 @@ internal fun DeviceTabPage( .padding(contentPadding) .padding( horizontal = UiSpacing.PageHorizontal, - vertical = UiSpacing.PageVertical, + vertical = UiSpacing.PageVertical ), horizontalArrangement = Arrangement.spacedBy(UiSpacing.PageItem), ) { @@ -1459,14 +746,12 @@ internal fun DeviceTabPage( val desiredPreviewWidth = (asBundle.devicePreviewCardHeightDp.coerceAtLeast(120) .toFloat() * currentSession.width / currentSession.height).dp - val previewWidth = - desiredPreviewWidth.coerceAtMost(twoPaneContentWidth * 2f / 3f) + val previewWidth = desiredPreviewWidth + .coerceAtMost(twoPaneContentWidth * 2f / 3f) val configColumnWidth = (twoPaneContentWidth - previewWidth) .coerceIn(0.dp, DEVICE_TWO_PANE_CONFIG_MAX_WIDTH) - val previewPaneWidth = - (twoPaneContentWidth - configColumnWidth) - .coerceAtLeast(0.dp) + val previewPaneWidth = (twoPaneContentWidth - configColumnWidth).coerceAtLeast(0.dp) @Composable fun ConfigColumn() { @@ -1519,49 +804,24 @@ internal fun DeviceTabPage( loadingText = "最近任务加载中", emptyText = "没有可用的最近任务", entries = recentTasks.map { task -> - val app = scrcpy.listings.findCachedApp(task.packageName) + val app = viewModel.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}") - } - } + viewModel.hideRecentTasks() + if (sessionInfo == null) viewModel.startScrcpy(task.packageName) + else viewModel.launchAppWithFallback(task.packageName) }, ) }, refreshBusy = listingsRefreshBusy, - onDismissRequest = { showRecentTasksSheet = false }, + onDismissRequest = { viewModel.hideRecentTasks() }, onRefresh = { scope.launch(Dispatchers.IO) { - runCatching { - scrcpy.listings.getRecentTasks(forceRefresh = true) - }.onFailure { error -> - logEvent("获取最近任务失败: ${error.message}", Log.WARN, error) - withContext(Dispatchers.Main) { - snackbar.show("获取最近任务失败") - } - } + viewModel.refreshRecentTasks() } }, ) @@ -1578,37 +838,14 @@ internal fun DeviceTabPage( 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}") - } - } - } + viewModel.hideAllApps() + if (sessionInfo == null) viewModel.startScrcpy(app.packageName) + else viewModel.launchAppWithFallback(app.packageName) }, ) }, refreshBusy = listingsRefreshBusy, - onDismissRequest = { showAllAppsSheet = false }, - onRefresh = { scope.launch(Dispatchers.IO) { refreshApps() } }, + onDismissRequest = { viewModel.hideAllApps() }, + onRefresh = { scope.launch(Dispatchers.IO) { viewModel.refreshApps() } }, ) } - -private val DEVICE_TWO_PANE_CONFIG_MAX_WIDTH = 640.dp diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt new file mode 100644 index 0000000..b2bde85 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt @@ -0,0 +1,954 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import io.github.miuzarte.scrcpyforandroid.StreamActivity +import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn +import io.github.miuzarte.scrcpyforandroid.services.DisconnectCause +import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles +import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction +import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +private const val ADB_CONNECT_TIMEOUT_MS = 12_000L +private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L +private const val ADB_KEEPALIVE_TIMEOUT_MS = 1_500L +private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 2_000L +private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 2_000L +private const val ADB_TCP_PROBE_TIMEOUT_MS = 500 + +@OptIn(FlowPreview::class) +internal class DeviceTabViewModel( + internal val scrcpy: Scrcpy, + connectionServices: DeviceConnectionServices, +) : ViewModel() { + + val scrcpyListings: Scrcpy.Listings get() = scrcpy.listings + + private val adbCoordinator = connectionServices.adbCoordinator + private val connectionStateStore = connectionServices.connectionStateStore + private val connectionController = connectionServices.connectionController + private val autoReconnectManager = connectionServices.autoReconnectManager + + private val _asBundle = MutableStateFlow(appSettings.bundleState.value) + val asBundle: StateFlow = _asBundle.asStateFlow() + + private val _qdBundle = MutableStateFlow(quickDevices.bundleState.value) + + val soBundle: StateFlow = scrcpyOptions.bundleState + val scrcpyProfilesState = scrcpyProfiles.state + val connectionState = connectionStateStore.state + val sessionInfo: StateFlow = scrcpy.currentSessionState + val listingsRefreshBusy: StateFlow = scrcpy.listings.refreshBusyState + val listingsRefreshVersion: StateFlow = scrcpy.listings.refreshVersionState + + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + + private val _adbConnecting = MutableStateFlow(false) + val adbConnecting: StateFlow = _adbConnecting.asStateFlow() + + private val _editingDeviceId = MutableStateFlow(null) + val editingDeviceId: StateFlow = _editingDeviceId.asStateFlow() + + private val _activeDeviceActionId = MutableStateFlow(null) + val activeDeviceActionId: StateFlow = _activeDeviceActionId.asStateFlow() + + private val _showRecentTasksSheet = MutableStateFlow(false) + val showRecentTasksSheet: StateFlow = _showRecentTasksSheet.asStateFlow() + + private val _showAllAppsSheet = MutableStateFlow(false) + val showAllAppsSheet: StateFlow = _showAllAppsSheet.asStateFlow() + + private val _imeRequestToken = MutableStateFlow(0) + val imeRequestToken: StateFlow = _imeRequestToken.asStateFlow() + + private val _pendingScrollToPreview = MutableStateFlow(false) + val pendingScrollToPreview: StateFlow = _pendingScrollToPreview.asStateFlow() + + private val _quickConnectInput = MutableStateFlow(_qdBundle.value.quickConnectInput) + val quickConnectInput: StateFlow = _quickConnectInput.asStateFlow() + + private val _savedShortcuts = MutableStateFlow( + DeviceShortcuts.unmarshalFrom(_qdBundle.value.quickDevicesList) + ) + val savedShortcuts: StateFlow = _savedShortcuts.asStateFlow() + + private val _isAppInForeground = MutableStateFlow(true) + val isAppInForeground: StateFlow = _isAppInForeground.asStateFlow() + + private val sessionReconnectBlacklistHosts = mutableSetOf() + + val adbConnected: StateFlow = connectionState + .map { it.adbSession.isConnected } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + connectionState.value.adbSession.isConnected, + ) + + val statusLine: StateFlow = connectionState + .map { it.adbSession.statusLine } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + connectionState.value.adbSession.statusLine, + ) + + val isQuickConnected: StateFlow = connectionState + .map { it.adbSession.isQuickConnected } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + connectionState.value.adbSession.isQuickConnected, + ) + + val currentTarget: StateFlow = connectionState + .map { it.adbSession.currentTarget } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + connectionState.value.adbSession.currentTarget, + ) + + val connectedDeviceLabel: StateFlow = connectionState + .map { it.adbSession.connectedDeviceLabel } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + connectionState.value.adbSession.connectedDeviceLabel, + ) + + val connectedScrcpyProfileId: StateFlow = combine( + connectionState.map { it.adbSession }, + _savedShortcuts, + ) { session, shortcuts -> + val target = session.currentTarget + if (session.isConnected && target != null) + shortcuts.get(target.host, target.port)?.scrcpyProfileId + ?: session.connectedScrcpyProfileId + else + session.connectedScrcpyProfileId + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + connectionState.value.adbSession.connectedScrcpyProfileId, + ) + + val connectedScrcpyBundle: StateFlow = combine( + connectedScrcpyProfileId, + soBundle, + scrcpyProfilesState, + ) { profileId, globalBundle, profiles -> + if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) globalBundle + else profiles.profiles.firstOrNull { it.id == profileId }?.bundle ?: globalBundle + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + soBundle.value, + ) + + val connectedVideoPlaybackEnabled: StateFlow = connectedScrcpyBundle + .map { it.video && it.videoPlayback } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + false, + ) + + val connectedScrcpyProfileName: StateFlow = combine( + connectedScrcpyProfileId, + scrcpyProfilesState, + ) { profileId, profiles -> + profiles.profiles + .firstOrNull { it.id == profileId } + ?.name + ?: ScrcpyOptions.GLOBAL_PROFILE_NAME + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + ScrcpyOptions.GLOBAL_PROFILE_NAME, + ) + + val canShowPreviewControls: StateFlow = combine( + adbConnected, + connectedVideoPlaybackEnabled, + sessionInfo, + ) { connected, videoPlayback, info -> + connected && videoPlayback && info != null && info.width > 0 && info.height > 0 + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + false, + ) + + val virtualButtonLayout: StateFlow, List>> = + _asBundle.map { + VirtualButtonActions.splitLayout( + VirtualButtonActions.parseStoredLayout(it.virtualButtonsLayout) + ) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + VirtualButtonActions.splitLayout( + VirtualButtonActions.parseStoredLayout(_asBundle.value.virtualButtonsLayout) + ), + ) + + private val _snackbarEvents = Channel(Channel.BUFFERED) + val snackbarEvents: Flow = _snackbarEvents.receiveAsFlow() + + private val _fullscreenRequests = Channel(Channel.BUFFERED) + val fullscreenRequests: Flow = _fullscreenRequests.receiveAsFlow() + + init { + // Sync asBundle from storage -> local + viewModelScope.launch { + appSettings.bundleState.collectLatest { shared -> + if (_asBundle.value != shared) { + _asBundle.value = shared + } + } + } + + // Debounced save asBundle local -> storage + viewModelScope.launch { + _asBundle.debounce(Settings.BUNDLE_SAVE_DELAY).collectLatest { bundle -> + if (bundle != appSettings.bundleState.value) { + appSettings.saveBundle(bundle) + } + } + } + + // Sync qdBundle from storage -> local + viewModelScope.launch { + quickDevices.bundleState.collectLatest { shared -> + if (_qdBundle.value != shared) { + _qdBundle.value = shared + } + } + } + + // Debounced save qdBundle local -> storage + viewModelScope.launch { + _qdBundle.debounce(Settings.BUNDLE_SAVE_DELAY).collectLatest { bundle -> + if (bundle != quickDevices.bundleState.value) { + quickDevices.saveBundle(bundle) + } + } + } + + // Sync savedShortcuts from qdBundle serialized list + viewModelScope.launch { + _qdBundle.collectLatest { bundle -> + val parsed = DeviceShortcuts.unmarshalFrom(bundle.quickDevicesList) + if (parsed.marshalToString() != _savedShortcuts.value.marshalToString()) { + _savedShortcuts.value = parsed + } + } + } + + // Persist savedShortcuts -> qdBundle (debounced) + viewModelScope.launch { + _savedShortcuts.debounce(Settings.BUNDLE_SAVE_DELAY).collectLatest { shortcuts -> + val serialized = shortcuts.marshalToString() + if (serialized != _qdBundle.value.quickDevicesList) { + _qdBundle.update { it.copy(quickDevicesList = serialized) } + } + } + } + } + + override fun onCleared() { + runBlocking(Dispatchers.IO) { + appSettings.saveBundle(_asBundle.value) + quickDevices.saveBundle(_qdBundle.value) + } + } + + fun resolveScrcpyBundle(profileId: String): ScrcpyOptions.Bundle { + if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) return soBundle.value + return scrcpyProfilesState.value.profiles + .firstOrNull { it.id == profileId } + ?.bundle + ?: soBundle.value + } + + fun setQuickConnectInput(value: String) { + _quickConnectInput.value = value + } + + fun saveQuickConnectInput() { + _qdBundle.update { it.copy(quickConnectInput = _quickConnectInput.value) } + } + + fun setEditingDeviceId(id: String?) { + _editingDeviceId.value = id + } + + fun setAppInForeground(foreground: Boolean) { + _isAppInForeground.value = foreground + } + + fun showRecentTasks() { + _showRecentTasksSheet.value = true + } + + fun hideRecentTasks() { + _showRecentTasksSheet.value = false + } + + fun showAllApps() { + _showAllAppsSheet.value = true + } + + fun hideAllApps() { + _showAllAppsSheet.value = false + } + + fun toggleIme() { + _imeRequestToken.update { it + 1 } + } + + fun updateAsBundle(transform: (AppSettings.Bundle) -> AppSettings.Bundle) { + _asBundle.update(transform) + } + + fun updateShortcut( + id: String? = null, + host: String? = null, + port: Int? = null, + name: String? = null, + startScrcpyOnConnect: Boolean? = null, + openFullscreenOnStart: Boolean? = null, + scrcpyProfileId: String? = null, + newPort: Int? = null, + updateNameOnlyWhenEmpty: Boolean = false, + ) { + _savedShortcuts.update { + it.update( + id, + host, + port, + name, + startScrcpyOnConnect, + openFullscreenOnStart, + scrcpyProfileId, + newPort, + updateNameOnlyWhenEmpty, + ) + } + } + + fun upsertShortcut(shortcut: DeviceShortcut) { + _savedShortcuts.update { it.upsert(shortcut) } + } + + fun removeShortcut(id: String) { + _savedShortcuts.update { it.remove(id) } + } + + fun handleVirtualButtonAction(action: VirtualButtonAction) { + when (action) { + VirtualButtonAction.RECENT_TASKS -> _showRecentTasksSheet.value = true + VirtualButtonAction.ALL_APPS -> _showAllAppsSheet.value = true + VirtualButtonAction.TOGGLE_IME -> _imeRequestToken.update { it + 1 } + VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> { /* handled by Composable with context */ + } + + else -> { + val keycode = action.keycode ?: return + runBusy("发送 ${action.title}") { + scrcpy.injectKeycode(0, keycode) + scrcpy.injectKeycode(1, keycode) + } + } + } + } + + private fun snackbar(message: String) { + _snackbarEvents.trySend(message) + } + + fun startScrcpy() = runBusy("启动 scrcpy") { startScrcpySession() } + + fun stopScrcpy() = runBusy("停止 scrcpy") { stopScrcpySession() } + + fun startScrcpy(packageName: String) = runBusy("启动 scrcpy") { + startScrcpySession(startAppOverride = packageName) + } + + fun launchAppWithFallback(packageName: String) = runBusy("启动应用") { + runCatching { scrcpy.startApp(packageName) } + .onSuccess { logEvent("已在当前显示启动应用: $packageName") } + .onFailure { error -> + snackbar("通过 scrcpy 控制通道启动应用失败,回退 ADB") + logEvent( + "通过 scrcpy 控制通道启动应用失败,回退 ADB" + + ": ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}", + Log.WARN, error, + ) + adbCoordinator.startApp(packageName = packageName) + logEvent("已通过 ADB 启动应用: $packageName") + } + } + + private fun runBusy( + label: String, + onFinished: (() -> Unit)? = null, + block: suspend () -> Unit + ) { + if (_busy.value) return + viewModelScope.launch { + _busy.value = true + try { + block() + } catch (_: TimeoutCancellationException) { + logEvent("$label 超时", Log.WARN) + } catch (e: IllegalArgumentException) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 参数错误: $detail", Log.WARN, e) + snackbar("$label 参数错误: $detail") + } catch (e: Exception) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 失败: $detail", Log.ERROR, e) + } finally { + _busy.value = false + onFinished?.invoke() + } + } + } + + private fun runAdbConnect( + label: String, + onStarted: (() -> Unit)? = null, + onFinished: (() -> Unit)? = null, + block: suspend () -> Unit, + ) { + if (_adbConnecting.value) return + viewModelScope.launch { + onStarted?.invoke() + _adbConnecting.value = true + try { + block() + } catch (_: TimeoutCancellationException) { + logEvent("$label 超时", Log.WARN) + } catch (e: IllegalArgumentException) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 参数错误: $detail", Log.WARN, e) + snackbar("$label 参数错误: $detail") + } catch (e: Exception) { + val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName + logEvent("$label 失败: $detail", Log.ERROR, e) + } finally { + _adbConnecting.value = false + onFinished?.invoke() + } + } + } + + suspend fun disconnectAdbConnection( + clearQuickOnlineForTarget: ConnectionTarget? = currentTarget.value, + logMessage: String? = null, + showSnackMessage: String? = null, + cause: DisconnectCause = DisconnectCause.User, + statusLine: String = "未连接", + ) { + val result = connectionController.disconnectAdbConnection( + clearQuickOnlineForTarget, + cause, + statusLine + ) + result.clearedTarget?.let { target -> + if (target.host.isNotBlank()) + _savedShortcuts.update { it.update(host = target.host, port = target.port) } + } + logMessage?.let { logEvent(it) } + if (!showSnackMessage.isNullOrBlank()) snackbar(showSnackMessage) + } + + suspend fun disconnectCurrentTargetBeforeConnecting(newHost: String, newPort: Int) { + val disconnected = + connectionController.disconnectCurrentTargetBeforeConnecting(newHost, newPort) ?: return + sessionReconnectBlacklistHosts += disconnected.host + if (disconnected.host.isNotBlank()) + _savedShortcuts.update { it.update(host = disconnected.host, port = disconnected.port) } + } + + suspend fun connectWithTimeout(host: String, port: Int) { + connectionController.connectWithTimeout(host, port, ADB_CONNECT_TIMEOUT_MS) + } + + fun applyConnectedDeviceCapabilities(sdkInt: Int) { + connectionController.applyConnectedDeviceCapabilities(sdkInt) + } + + suspend fun handleAdbConnected( + host: String, port: Int, + autoStartScrcpy: Boolean = false, + autoEnterFullScreen: Boolean = false, + scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID, + ) { + val connected = connectionController.handleAdbConnected(host, port, scrcpyProfileId) + val info = connected.info + val fullLabel = + if (info.serial.isNotBlank()) "${info.model} (${info.serial})" else info.model + + applyConnectedDeviceCapabilities(info.sdkInt) + _savedShortcuts.update { + it.update( + host = host, + port = port, + name = fullLabel, + updateNameOnlyWhenEmpty = true + ) + } + + logEvent( + "ADB 已连接: model=${info.model}, serial=${info.serial.ifBlank { "unknown" }}, " + + "manufacturer=${info.manufacturer.ifBlank { "unknown" }}, brand=${info.brand.ifBlank { "unknown" }}, " + + "device=${info.device.ifBlank { "unknown" }}, android=${info.androidRelease.ifBlank { "unknown" }}, sdk=${info.sdkInt}" + ) + snackbar("ADB 已连接") + + if (_asBundle.value.adbAutoLoadAppListOnConnect) { + viewModelScope.launch(Dispatchers.IO) { + runCatching { scrcpy.listings.getApps(forceRefresh = true) } + .onFailure { error -> + logEvent( + "获取应用列表失败: ${error.message}", + Log.WARN, + error + ) + } + } + } + + if (autoStartScrcpy && sessionInfo.value == null) { + runBusy("启动 scrcpy") { + startScrcpySession(openFullscreen = autoStartScrcpy && autoEnterFullScreen) + } + } + } + + suspend fun startScrcpySession( + openFullscreen: Boolean = false, + startAppOverride: String? = null, + ) { + val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId.value) + val options = scrcpyOptions.toClientOptions(activeBundle).fix() + val resolvedOptions = startAppOverride + ?.takeIf { it.isNotBlank() } + ?.let { options.copy(startApp = it) } + ?: options + val session = scrcpy.start(resolvedOptions) + _pendingScrollToPreview.value = resolvedOptions.video && resolvedOptions.videoPlayback + + if (resolvedOptions.startApp.isNotBlank() && resolvedOptions.control) { + runCatching { scrcpy.startApp(resolvedOptions.startApp) } + .onSuccess { logEvent("已请求 scrcpy 启动应用: ${resolvedOptions.startApp}") } + .onFailure { error -> + logEvent( + "通过 scrcpy 控制通道启动应用失败: ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}", + Log.WARN, error + ) + } + } + + if ((resolvedOptions.fullscreen || openFullscreen) && + resolvedOptions.video && resolvedOptions.videoPlayback + ) { + _fullscreenRequests.trySend(Unit) + } + if (resolvedOptions.disableScreensaver) AppScreenOn.acquire() + connectionController.markScrcpyStarted() + + @SuppressLint("DefaultLocale") + val videoDetail = if (!resolvedOptions.video) "off" + else if (activeBundle.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default" + else "${session.codec?.string ?: "null"} ${session.width}x${session.height} @${ + String.format( + "%.1f", + activeBundle.videoBitRate / 1_000_000f + ) + }Mbps" + + val audioDetail = if (!activeBundle.audio) "off" + else if (activeBundle.audioBitRate <= 0) "${resolvedOptions.audioCodec} default source=${resolvedOptions.audioSource}" + else "${resolvedOptions.audioCodec} ${activeBundle.audioBitRate / 1_000f}Kbps source=${resolvedOptions.audioSource}${if (!resolvedOptions.audioPlayback) "(no-playback)" else ""}" + + logEvent( + "scrcpy 已启动: device=${session.deviceName}, video=$videoDetail, audio=$audioDetail, " + + "control=${resolvedOptions.control}, turnScreenOff=${resolvedOptions.turnScreenOff}, " + + "maxSize=${resolvedOptions.maxSize}, maxFps=${resolvedOptions.maxFps}" + ) + snackbar("scrcpy 已启动" + if (resolvedOptions.recordFilename.isNotBlank()) "并开始录制" else "") + } + + suspend fun stopScrcpySession() { + val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId.value) + val options = scrcpyOptions.toClientOptions(activeBundle).fix() + if (options.killAdbOnClose) { + currentTarget.value?.host?.let { sessionReconnectBlacklistHosts += it } + val result = connectionController.stopScrcpySession(killAdbOnClose = true) + result.clearedTarget?.let { target -> + if (target.host.isNotBlank()) + _savedShortcuts.update { it.update(host = target.host, port = target.port) } + } + logEvent("scrcpy 已停止,ADB 已断开") + snackbar("scrcpy 已停止,ADB 已断开") + } else { + connectionController.stopScrcpySession(killAdbOnClose = false) + logEvent("scrcpy 已停止") + snackbar("scrcpy 已停止") + } + } + + fun shouldOpenFullscreenCompat(): Boolean = _asBundle.value.fullscreenCompatibilityMode + + fun openStreamActivity(context: Context) { + context.startActivity(StreamActivity.createIntent(context)) + } + + suspend fun refreshApps() { + runCatching { scrcpy.listings.getApps(forceRefresh = true) } + .onFailure { error -> + logEvent("获取应用列表失败: ${error.message}", Log.WARN, error) + withContext(Dispatchers.Main) { snackbar("获取应用列表失败") } + } + } + + suspend fun refreshRecentTasks() { + runCatching { scrcpy.listings.getRecentTasks(forceRefresh = true) } + .onFailure { error -> + logEvent("获取最近任务失败: ${error.message}", Log.WARN, error) + withContext(Dispatchers.Main) { snackbar("获取最近任务失败") } + } + } + + suspend fun pasteLocalClipboard(context: Context) { + val text = + io.github.miuzarte.scrcpyforandroid.services.LocalInputService.getClipboardText(context) + ?.takeIf { it.isNotBlank() } + if (text == null) { + snackbar("本机剪贴板为空或不是文本") + return + } + val useLegacyPaste = connectedScrcpyBundle.value.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(if (useLegacyPaste) "legacy 粘贴失败" else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste") + } + } + + suspend fun commitImeText(text: String) { + submitImeText( + scrcpy = scrcpy, text = text, + keyInjectMode = scrcpyOptions.toClientOptions(connectedScrcpyBundle.value).keyInjectMode, + ) { error, useClipboardPaste -> + logEvent("输入法文本提交失败: ${error.message}", Log.WARN, error) + snackbar(if (useClipboardPaste) "非 ASCII 文本粘贴失败" else "文本输入失败") + } + } + + fun onDeviceAction(device: DeviceShortcut) { + val connected = adbConnected.value && + currentTarget.value?.host == device.host && currentTarget.value?.port == device.port + + if (!connected) { + runAdbConnect( + "连接 ADB", + { _activeDeviceActionId.value = device.id }, + { _activeDeviceActionId.value = null }) { + disconnectCurrentTargetBeforeConnecting(device.host, device.port) + try { + connectWithTimeout(device.host, device.port) + handleAdbConnected( + device.host, + device.port, + device.startScrcpyOnConnect, + device.startScrcpyOnConnect && device.openFullscreenOnStart, + device.scrcpyProfileId + ) + connectionController.updateQuickConnected(false) + } catch (error: Exception) { + connectionController.markConnectionFailed(error) + logEvent("ADB 连接失败: ${error.message}", Log.ERROR) + snackbar("ADB 连接失败") + } + } + return + } + + runAdbConnect( + "断开 ADB", + { _activeDeviceActionId.value = device.id }, + { _activeDeviceActionId.value = null }) { + sessionReconnectBlacklistHosts += device.host + disconnectAdbConnection( + ConnectionTarget(device.host, device.port), + "ADB 已断开: ${device.name}", + "ADB 已断开" + ) + } + } + + fun onQuickConnect(target: ConnectionTarget) { + runAdbConnect( + "连接 ADB", + { _activeDeviceActionId.value = target.toString() }, + { _activeDeviceActionId.value = null }) { + disconnectCurrentTargetBeforeConnecting(target.host, target.port) + try { + connectWithTimeout(target.host, target.port) + handleAdbConnected( + target.host, target.port, + autoStartScrcpy = false, + autoEnterFullScreen = false, + scrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID, + ) + connectionController.updateQuickConnected(true) + } catch (error: Exception) { + connectionController.markConnectionFailed(error) + logEvent("ADB 连接失败: ${error.message}", Log.ERROR) + snackbar("ADB 连接失败") + } + } + } + + fun onDisconnectCurrent(target: ConnectionTarget?) { + runAdbConnect("断开 ADB") { + target?.let { + sessionReconnectBlacklistHosts += it.host + disconnectAdbConnection(it, "ADB 已断开", "ADB 已断开") + } + } + } + + fun onPair(host: String, port: String, code: String) { + runBusy("执行配对") { + val h = host.trim() + val p = port.trim().toIntOrNull() ?: return@runBusy + val c = code.trim() + val ok = adbCoordinator.pair(h, p, c) + logEvent(if (ok) "配对成功" else "配对失败", if (ok) Log.INFO else Log.ERROR) + snackbar(if (ok) "配对成功" else "配对失败") + } + } + + suspend fun onDiscoverPairingTarget(): Pair? { + return adbCoordinator.discoverPairingService(includeLanDevices = _asBundle.value.adbMdnsLanDiscovery) + } + + fun blacklistHost(host: String) { + sessionReconnectBlacklistHosts += host + } + + fun startKeepAliveLoop() { + if (_keepAliveLoopStarted) return + _keepAliveLoopStarted = true + viewModelScope.launch { + try { + autoReconnectManager.runKeepAliveLoop( + isForeground = { _isAppInForeground.value }, + intervalMs = ADB_KEEPALIVE_INTERVAL_MS, + connectTimeoutMs = ADB_CONNECT_TIMEOUT_MS, + keepAliveTimeoutMs = ADB_KEEPALIVE_TIMEOUT_MS, + onReconnectSuccess = { host, port -> + logEvent("ADB 自动重连成功: $host:$port") + snackbar("ADB 自动重连成功") + }, + onReconnectFailure = { error -> + viewModelScope.launch { + disconnectAdbConnection( + cause = DisconnectCause.KeepAliveFailed, + statusLine = "ADB 连接断开" + ) + } + logEvent("ADB 自动重连失败: $error", Log.ERROR) + snackbar("ADB 自动重连失败") + }, + ) + } finally { + _keepAliveLoopStarted = false + } + } + } + + private var _keepAliveLoopStarted = false + + fun startAutoReconnectLoop() { + if (_autoReconnectLoopStarted) return + _autoReconnectLoopStarted = true + viewModelScope.launch { + autoReconnectManager.runAutoReconnectLoop( + isForeground = { _isAppInForeground.value }, + isAutoReconnectEnabled = { _asBundle.value.adbAutoReconnectPairedDevice }, + isBusy = { _busy.value }, + isAdbConnecting = { _adbConnecting.value }, + hasActiveSession = { sessionInfo.value != null }, + savedShortcuts = { _savedShortcuts.value.toList() }, + isBlacklisted = { host -> sessionReconnectBlacklistHosts.contains(host) }, + connectTimeoutMs = ADB_CONNECT_TIMEOUT_MS, + probeTimeoutMs = ADB_TCP_PROBE_TIMEOUT_MS, + discoverConnectService = { + adbCoordinator.discoverConnectService( + ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS, + _asBundle.value.adbMdnsLanDiscovery + ) + }, + onMdnsPortChanged = { host, oldPort, newPort -> + _savedShortcuts.update { + it.update( + host = host, + port = oldPort, + newPort = newPort + ) + } + logEvent("mDNS 发现新端口,已更新快速设备: $host:$oldPort -> $host:$newPort") + }, + onKnownDeviceReconnected = { target -> + _savedShortcuts.update { it.update(host = target.host, port = target.port) } + logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}") + }, + onDiscoveredDeviceReconnected = { host, port, _ -> + _savedShortcuts.update { it.update(host = host, port = port) } + logEvent("ADB 自动重连成功: $host:$port") + }, + retryIntervalMs = ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS, + ) + } + } + + private var _autoReconnectLoopStarted = false + + fun startProfileIdSync() { + if (_profileIdSyncStarted) return + _profileIdSyncStarted = true + viewModelScope.launch { + combine(adbConnected, currentTarget, _savedShortcuts) { connected, target, shortcuts -> + Triple(connected, target, shortcuts) + }.collect { (connected, target, shortcuts) -> + if (!connected || target == null) return@collect + val boundProfileId = shortcuts.get(target.host, target.port) + ?.scrcpyProfileId ?: ScrcpyOptions.GLOBAL_PROFILE_ID + if (boundProfileId != connectionState.value.adbSession.connectedScrcpyProfileId) { + connectionController.syncConnectedScrcpyProfileId(boundProfileId) + logEvent("当前连接设备已切换为配置: $boundProfileId") + } + } + } + } + + private var _profileIdSyncStarted = false + + fun startRecentTasksAutoRefresh() { + if (_recentTasksAutoRefreshStarted) return + _recentTasksAutoRefreshStarted = true + viewModelScope.launch { + combine(adbConnected, currentTarget, _isAppInForeground) { connected, _, foreground -> + connected && foreground + }.collect { shouldRefresh -> + if (!shouldRefresh) return@collect + withContext(Dispatchers.IO) { + runCatching { scrcpy.listings.getRecentTasks(forceRefresh = true) } + .onFailure { error -> + logEvent( + "获取最近任务失败: ${error.message}", + Log.WARN, + error + ) + } + } + } + } + } + + private var _recentTasksAutoRefreshStarted = false + + fun clearPendingScrollToPreview() { + _pendingScrollToPreview.value = false + } + + suspend fun injectTouch( + action: Int, pointerId: Long, x: Int, y: Int, + screenWidth: Int, screenHeight: Int, pressure: Float, + actionButton: Int = 0, buttons: Int = 0, + ) { + scrcpy.injectTouch( + action, + pointerId, + x, + y, + screenWidth, + screenHeight, + pressure, + actionButton, + buttons + ) + } + + suspend fun pressBackOrTurnScreenOn(action: Int) { + scrcpy.pressBackOrTurnScreenOn(action) + } + + fun findCachedApp(packageName: String): Scrcpy.AppInfo? = + scrcpy.listings.findCachedApp(packageName) + + suspend fun startApp(packageName: String) { + scrcpy.startApp(packageName) + } + + suspend fun startAppViaAdb(packageName: String) { + adbCoordinator.startApp(packageName = packageName) + } + + class Factory( + private val scrcpy: Scrcpy, + private val connectionServices: DeviceConnectionServices, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return DeviceTabViewModel(scrcpy, connectionServices) as T + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt index 113677c..ec21a71 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt @@ -1,7 +1,5 @@ package io.github.miuzarte.scrcpyforandroid.pages -import android.content.Intent -import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.combinedClickable @@ -15,20 +13,16 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile -import androidx.compose.material.icons.rounded.Android -import androidx.compose.material.icons.rounded.Archive -import androidx.compose.material.icons.rounded.AudioFile -import androidx.compose.material.icons.rounded.Description import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.Image import androidx.compose.material.icons.rounded.Link -import androidx.compose.material.icons.rounded.Movie import androidx.compose.material.icons.rounded.RawOff import androidx.compose.material.icons.rounded.RawOn import androidx.compose.runtime.Composable @@ -36,8 +30,6 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -50,37 +42,30 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.services.DirectoryDownloadSnapshot -import io.github.miuzarte.scrcpyforandroid.services.DirectorySnapshotSession import io.github.miuzarte.scrcpyforandroid.services.FileManagerService import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.RemoteFileEntry import io.github.miuzarte.scrcpyforandroid.services.RemoteFileKind import io.github.miuzarte.scrcpyforandroid.services.RemoteFileStat -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.MultiGroupsDropdown -import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdownGroup -import io.github.miuzarte.scrcpyforandroid.widgets.PopupMenuItem -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import top.yukonga.miuix.kmp.basic.ButtonDefaults import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.DropdownImpl +import top.yukonga.miuix.kmp.basic.HorizontalDivider import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.ListPopupColumn import top.yukonga.miuix.kmp.basic.ListPopupDefaults import top.yukonga.miuix.kmp.basic.PullToRefresh +import top.yukonga.miuix.kmp.basic.PullToRefreshState import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.SmallTopAppBar import top.yukonga.miuix.kmp.basic.Text @@ -96,289 +81,78 @@ import top.yukonga.miuix.kmp.overlay.OverlayDialog import top.yukonga.miuix.kmp.overlay.OverlayListPopup import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme -private const val ROOT_REMOTE_PATH = "/" private const val INITIAL_REMOTE_PATH = "/storage/emulated/0" -private enum class FileManagerSortField { - NAME, - SIZE, - TIME, - EXTENSION, -} - -private data class FileManagerScrollPosition( - val index: Int, - val offset: Int, -) - -private sealed interface PendingTreeDownload { - data class File( - val remotePath: String, - val fileName: String, - ) : PendingTreeDownload - - data class Directory( - val snapshot: DirectoryDownloadSnapshot, - ) : PendingTreeDownload -} - @Composable fun FileManagerScreen( bottomInnerPadding: Dp, onCanNavigateUpChange: (Boolean) -> Unit = {}, onNavigateUpActionChange: (((() -> Boolean)?) -> Unit)? = null, ) { + val viewModel: FileManagerViewModel = viewModel() val context = LocalContext.current val snackbar = LocalSnackbarController.current val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current) val blurActive = blurBackdrop != null - val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val pullToRefreshState = rememberPullToRefreshState() val listState = rememberLazyListState() val layoutDirection = LocalLayoutDirection.current - val asBundle by appSettings.bundleState.collectAsState() - val pathStack = remember { - mutableStateListOf().apply { - addAll(buildPathStack(INITIAL_REMOTE_PATH)) - } - } - val currentPath = pathStack.lastOrNull() ?: INITIAL_REMOTE_PATH - val directoryCache = remember { mutableStateMapOf>() } - val directoryScrollCache = remember { mutableStateMapOf() } - var loading by rememberSaveable { mutableStateOf(false) } - var isRefreshing by rememberSaveable { mutableStateOf(false) } - var errorText by rememberSaveable { mutableStateOf(null) } + val pathStack by viewModel.pathStack.collectAsState() + val currentPath by viewModel.currentPath.collectAsState() + val cachedEntries by viewModel.cachedEntries.collectAsState() + val loading by viewModel.loading.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() + val errorText by viewModel.errorText.collectAsState() + val displayedEntries by viewModel.displayedEntries.collectAsState() + val sortField by viewModel.sortField.collectAsState() + val sortDescending by viewModel.sortDescending.collectAsState() + val directoryScrollCache by viewModel.directoryScrollCache.collectAsState() + val pendingTreeDownload by viewModel.pendingTreeDownload.collectAsState() + val canNavigateUp by viewModel.canNavigateUp.collectAsState() + val detailLoading by viewModel.detailLoading.collectAsState() + val selectedEntry by viewModel.selectedEntry.collectAsState() + val selectedStat by viewModel.selectedStat.collectAsState() + val selectedTargetStat by viewModel.selectedTargetStat.collectAsState() + val selectedSnapshot by viewModel.selectedSnapshot.collectAsState() + val showDetailsSheet by viewModel.showDetailsSheet.collectAsState() + val showRawDetails by viewModel.showRawDetails.collectAsState() + var showMenu by rememberSaveable { mutableStateOf(false) } var showSortMenu by rememberSaveable { mutableStateOf(false) } var showPathDialog by rememberSaveable { mutableStateOf(false) } var showCreateFolderDialog by rememberSaveable { mutableStateOf(false) } - var showDetailsSheet by rememberSaveable { mutableStateOf(false) } - var showRawDetails by rememberSaveable { mutableStateOf(false) } var pathInput by rememberSaveable { mutableStateOf(INITIAL_REMOTE_PATH) } var newFolderName by rememberSaveable { mutableStateOf("") } - var selectedEntry by remember { mutableStateOf(null) } - var selectedStat by remember { mutableStateOf(null) } - var selectedTargetStat by remember { mutableStateOf(null) } - var selectedSnapshot by remember { mutableStateOf(null) } - var detailLoading by rememberSaveable { mutableStateOf(false) } - var pendingTreeDownload by remember { mutableStateOf(null) } - var activeSnapshotSession by remember { mutableStateOf(null) } - - val sortField = remember(asBundle.fileManagerSortBy) { - runCatching { FileManagerSortField.valueOf(asBundle.fileManagerSortBy) } - .getOrDefault(FileManagerSortField.NAME) - } - val sortDescending = asBundle.fileManagerSortDescending - val cachedEntries = directoryCache[currentPath] - val displayedEntries = remember(cachedEntries, sortField, sortDescending) { - sortEntries(cachedEntries.orEmpty(), sortField, sortDescending) - } - - fun clearDetails() { - selectedEntry = null - selectedStat = null - selectedTargetStat = null - selectedSnapshot = null - detailLoading = false - showRawDetails = false - val session = activeSnapshotSession - activeSnapshotSession = null - if (session != null) { - taskScope.launch { - runCatching { session.interrupt() } - } - } - } - - fun dismissDetails() { - showDetailsSheet = false - } - - fun rememberCurrentScrollPosition() { - directoryScrollCache[currentPath] = FileManagerScrollPosition( - index = listState.firstVisibleItemIndex, - offset = listState.firstVisibleItemScrollOffset, - ) - } - - fun navigateUp(): Boolean { - if (pathStack.size <= 1) { - return false - } - rememberCurrentScrollPosition() - pathStack.removeAt(pathStack.lastIndex) - return true - } - - fun jumpToPath(rawPath: String) { - val normalized = normalizePath(rawPath) - rememberCurrentScrollPosition() - pathStack.clear() - pathStack.addAll(buildPathStack(normalized)) - } - - suspend fun reloadCurrentDirectory(force: Boolean) { - val cached = directoryCache[currentPath] - if (cached != null && !force) { - errorText = null - return - } - loading = cached == null - errorText = null - val result = runCatching { - FileManagerService.listDirectory(currentPath) - } - loading = false - isRefreshing = false - result.onSuccess { directoryCache[currentPath] = it } - .onFailure { error -> - errorText = error.message ?: error.javaClass.simpleName - } - } - - suspend fun startDownloadToTree(treeUri: Uri, request: PendingTreeDownload) { - when (request) { - is PendingTreeDownload.File -> { - FileManagerService.downloadFileToTree( - context = context, - treeUri = treeUri, - remotePath = request.remotePath, - fileName = request.fileName, - ) - } - - is PendingTreeDownload.Directory -> { - FileManagerService.downloadDirectoryToTree( - context = context, - treeUri = treeUri, - snapshot = request.snapshot, - ) - } - } - } - - fun requestDownload(entry: RemoteFileEntry) { - val snapshot = selectedSnapshot - dismissDetails() - snackbar.show("开始下载") - taskScope.launch { - if (entry.isDirectory) { - if (snapshot == null) { - withContext(Dispatchers.Main) { - snackbar.show("目录信息仍在加载,请稍后重试") - } - return@launch - } - val directSaved = FileManagerService.downloadDirectoryToPublicDownloads(snapshot) - if (directSaved) { - withContext(Dispatchers.Main) { snackbar.show("已下载到 Download/Scrcpy") } - } else { - withContext(Dispatchers.Main) { - pendingTreeDownload = PendingTreeDownload.Directory(snapshot) - snackbar.show("无法直接写入 Download/Scrcpy,请选择保存目录") - } - } - } else { - val directSaved = FileManagerService.downloadFileToPublicDownloads( - remotePath = entry.fullPath, - fileName = entry.name, - ) - if (directSaved) { - withContext(Dispatchers.Main) { snackbar.show("已下载到 Download/Scrcpy") } - } else { - withContext(Dispatchers.Main) { - pendingTreeDownload = PendingTreeDownload.File(entry.fullPath, entry.name) - snackbar.show("无法直接写入 Download/Scrcpy,请选择保存目录") - } - } - } - } - } - - fun updateSort(sortBy: FileManagerSortField? = null, descending: Boolean? = null) { - rememberCurrentScrollPosition() - taskScope.launch { - appSettings.updateBundle { - it.copy( - fileManagerSortBy = (sortBy ?: sortField).name, - fileManagerSortDescending = descending ?: sortDescending, - ) - } - } - } - - fun openPathDialog() { - pathInput = currentPath - showPathDialog = true - } - - fun openCreateFolderDialog() { - newFolderName = "" - showCreateFolderDialog = true - } val uploadLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri == null) return@rememberLauncherForActivityResult - taskScope.launch { - val result = runCatching { - context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - FileManagerService.uploadFile(context, uri, currentPath) - } - withContext(Dispatchers.Main) { - result.onSuccess { - snackbar.show("已上传到 $currentPath") - directoryCache.remove(currentPath) - isRefreshing = true - }.onFailure { error -> - snackbar.show("上传失败: ${error.message ?: error.javaClass.simpleName}") - } - } - } + viewModel.uploadFile(context, uri) } val treeLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> - val request = pendingTreeDownload - pendingTreeDownload = null - if (uri == null || request == null) return@rememberLauncherForActivityResult - taskScope.launch { - val result = runCatching { - context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - startDownloadToTree(uri, request) - } - withContext(Dispatchers.Main) { - result.onSuccess { - snackbar.show("下载完成") - }.onFailure { error -> - snackbar.show("下载失败: ${error.message ?: error.javaClass.simpleName}") - } - } - } + if (uri != null) viewModel.downloadToTree(context, uri) } + LaunchedEffect(Unit) { + viewModel.snackbarEvents.collect { snackbar.show(it) } + } + LaunchedEffect(currentPath) { - reloadCurrentDirectory(force = false) + viewModel.reloadCurrentDirectory(force = false) } LaunchedEffect(currentPath, sortField, sortDescending, displayedEntries.size, loading) { if (loading || displayedEntries.isEmpty()) return@LaunchedEffect - val scrollPosition = directoryScrollCache[currentPath] ?: return@LaunchedEffect - val targetIndex = scrollPosition.index.coerceIn(0, displayedEntries.lastIndex) listState.scrollToItem(targetIndex, scrollPosition.offset) } LaunchedEffect(isRefreshing) { - if (isRefreshing) reloadCurrentDirectory(force = true) + if (isRefreshing) viewModel.reloadCurrentDirectory(force = true) } LaunchedEffect(pendingTreeDownload) { @@ -386,9 +160,8 @@ fun FileManagerScreen( } DisposableEffect(pathStack.size) { - val canNavigateUp = pathStack.size > 1 onCanNavigateUpChange(canNavigateUp) - onNavigateUpActionChange?.invoke(::navigateUp) + onNavigateUpActionChange?.invoke(viewModel::navigateUp) onDispose { onCanNavigateUpChange(false) onNavigateUpActionChange?.invoke(null) @@ -396,93 +169,7 @@ fun FileManagerScreen( } DisposableEffect(Unit) { - onDispose { - clearDetails() - taskScope.cancel() - } - } - - fun openEntry(entry: RemoteFileEntry) { - when { - entry.isDirectory -> { - rememberCurrentScrollPosition() - pathStack.add(normalizePath(entry.fullPath)) - } - - entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> { - taskScope.launch { - val targetPath = resolveLinkTarget(entry) - if (targetPath == null) { - withContext(Dispatchers.Main) { - snackbar.show("链接目标不可用,长按查看信息") - } - return@launch - } - val result = runCatching { - FileManagerService.stat(targetPath) - } - withContext(Dispatchers.Main) { - result.onSuccess { targetStat -> - if (isDirectoryStat(targetStat)) { - jumpToPath(targetPath) - } else { - snackbar.show("链接目标不是文件夹,长按查看信息") - } - }.onFailure { error -> - snackbar.show("读取链接目标失败: ${error.message ?: error.javaClass.simpleName}") - } - } - } - } - - else -> snackbar.show("长按可查看文件详情") - } - } - - fun showEntryDetails(entry: RemoteFileEntry) { - clearDetails() - selectedEntry = entry - showDetailsSheet = true - detailLoading = true - taskScope.launch { - val statResult = runCatching { - FileManagerService.stat(entry.fullPath) - } - val linkTargetPath = resolveLinkTarget(entry) - val targetStatResult = - if (linkTargetPath != null) - runCatching { FileManagerService.stat(linkTargetPath) } - else null - - val snapshotResult = - if (entry.isDirectory) - runCatching { - val session = DirectorySnapshotSession.open() - withContext(Dispatchers.Main) { - activeSnapshotSession = session - } - session.load(entry.fullPath) - } - else null - - withContext(Dispatchers.Main) { - detailLoading = false - statResult - .onSuccess { selectedStat = it } - .onFailure { error -> - snackbar.show("读取详情失败: ${error.message ?: error.javaClass.simpleName}") - if (selectedEntry === entry) - selectedEntry = null - } - targetStatResult - ?.onSuccess { selectedTargetStat = it } - snapshotResult - ?.onSuccess { selectedSnapshot = it } - ?.onFailure { error -> - snackbar.show("目录扫描失败: ${error.message ?: error.javaClass.simpleName}") - } - } - } + onDispose { viewModel.clearDetails() } } Scaffold( @@ -490,10 +177,12 @@ fun FileManagerScreen( BlurredBar(backdrop = blurBackdrop) { SmallTopAppBar( title = "文件", - color = if (blurActive) Color.Transparent else colorScheme.surface, + color = + if (blurActive) Color.Transparent + else colorScheme.surface, navigationIcon = { IconButton( - onClick = ::navigateUp, + onClick = viewModel::navigateUp, enabled = pathStack.size > 1, ) { Icon( @@ -508,8 +197,14 @@ fun FileManagerScreen( modifier = Modifier .fillMaxWidth() .combinedClickable( - onClick = ::openPathDialog, - onLongClick = ::openPathDialog, + onClick = { + pathInput = currentPath + showPathDialog = true + }, + onLongClick = { + pathInput = currentPath + showPathDialog = true + }, ) .padding( start = UiSpacing.PageHorizontal, @@ -525,7 +220,7 @@ fun FileManagerScreen( Box { IconButton( onClick = { showSortMenu = true }, - holdDownState = showSortMenu, + holdDownState = showSortMenu ) { Icon( imageVector = MiuixIcons.Tune, @@ -538,45 +233,50 @@ fun FileManagerScreen( onDismissRequest = { showSortMenu = false }, ) { ListPopupColumn { - MultiGroupsDropdown( - groups = listOf( - MultiGroupsDropdownGroup( - options = listOf( - "文件名", - "大小", - "时间", - "扩展名" - ), - selectedIndex = when (sortField) { - FileManagerSortField.NAME -> 0 - FileManagerSortField.SIZE -> 1 - FileManagerSortField.TIME -> 2 - FileManagerSortField.EXTENSION -> 3 - }, - onSelectedIndexChange = { index -> - updateSort( - sortBy = when (index) { - 1 -> FileManagerSortField.SIZE - 2 -> FileManagerSortField.TIME - 3 -> FileManagerSortField.EXTENSION - else -> FileManagerSortField.NAME - } - ) - }, - ), - MultiGroupsDropdownGroup( - options = listOf("正序", "倒序"), - selectedIndex = if (sortDescending) 1 else 0, - onSelectedIndexChange = { index -> - updateSort(descending = index == 1) - }, - ), - ), - ) + val sortOptions = listOf("文件名", "大小", "时间", "扩展名") + val sortFieldIdx = when (sortField) { + FileManagerSortField.NAME -> 0 + FileManagerSortField.SIZE -> 1 + FileManagerSortField.TIME -> 2 + FileManagerSortField.EXTENSION -> 3 + } + sortOptions.forEachIndexed { i, option -> + DropdownImpl( + text = option, + optionSize = sortOptions.size, + isSelected = i == sortFieldIdx, + index = i, + onSelectedIndexChange = { index -> + viewModel.updateSort( + sortBy = when (index) { + 1 -> FileManagerSortField.SIZE + 2 -> FileManagerSortField.TIME + 3 -> FileManagerSortField.EXTENSION + else -> FileManagerSortField.NAME + } + ) + }, + ) + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp)) + val dirOptions = listOf("正序", "倒序") + val dirIdx = if (sortDescending) 1 else 0 + dirOptions.forEachIndexed { i, option -> + DropdownImpl( + text = option, + optionSize = dirOptions.size, + isSelected = i == dirIdx, + index = i, + onSelectedIndexChange = { index -> + viewModel.updateSort( + descending = index == 1 + ) + }, + ) + } } } } - Box { IconButton( onClick = { showMenu = true }, @@ -593,18 +293,21 @@ fun FileManagerScreen( onDismissRequest = { showMenu = false }, ) { ListPopupColumn { - PopupMenuItem( + DropdownImpl( text = "创建文件夹", optionSize = 2, + isSelected = false, index = 0, onSelectedIndexChange = { showMenu = false - openCreateFolderDialog() + newFolderName = "" + showCreateFolderDialog = true }, ) - PopupMenuItem( + DropdownImpl( text = "上传文件到该目录", optionSize = 2, + isSelected = false, index = 1, onSelectedIndexChange = { showMenu = false @@ -622,159 +325,170 @@ fun FileManagerScreen( Box( modifier = if (blurActive) Modifier.layerBackdrop(blurBackdrop) - else Modifier, + else Modifier ) { - PullToRefresh( + FileManagerPage( + contentPadding = pagePadding, + bottomInnerPadding = bottomInnerPadding, + loading = loading && cachedEntries == null, isRefreshing = isRefreshing, - onRefresh = { isRefreshing = true }, + errorText = if (cachedEntries == null) errorText else null, + displayedEntries = displayedEntries, pullToRefreshState = pullToRefreshState, - refreshTexts = listOf("下拉刷新", "释放刷新", "正在刷新...", "刷新完成"), - contentPadding = PaddingValues( - top = pagePadding.calculateTopPadding() + 12.dp, - ), + listState = listState, + layoutDirection = layoutDirection, + onRefresh = { viewModel.setRefreshing(true) }, + onOpenEntry = { entry -> + viewModel.saveScrollPosition( + currentPath, + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset + ) + viewModel.openEntry(entry) + }, + onShowEntryDetails = viewModel::showEntryDetails, + ) + } + } + + val entry = selectedEntry + if (entry != null || showDetailsSheet) { + FileDetailsBottomSheet( + show = showDetailsSheet, + content = when { + detailLoading -> "正在加载详情" + entry != null && selectedStat != null -> buildDetailsText( + stat = selectedStat!!, + targetStat = selectedTargetStat, + directorySnapshot = + if (entry.isDirectory) selectedSnapshot + else null, + showRaw = showRawDetails, + ) + + else -> "暂无详情" + }, + onDismissRequest = viewModel::dismissDetails, + onDismissFinished = viewModel::clearDetails, + onToggleRaw = viewModel::toggleRawDetails, + showingRaw = showRawDetails, + onDownload = { entry?.let { viewModel.requestDownload(it) } }, + downloadEnabled = entry != null + && !detailLoading + && (!entry.isDirectory || selectedSnapshot != null), + ) + } + if (showPathDialog) { + PathJumpDialog( + show = true, + path = pathInput, + onPathChange = { pathInput = it }, + onDismissRequest = { showPathDialog = false }, + onConfirm = { + showPathDialog = false + viewModel.jumpToPath(pathInput) + }, + ) + } + if (showCreateFolderDialog) { + CreateFolderDialog( + show = true, + folderName = newFolderName, + onFolderNameChange = { newFolderName = it }, + onDismissRequest = { showCreateFolderDialog = false }, + onConfirm = { + showCreateFolderDialog = false + viewModel.createFolder(newFolderName) + }, + ) + } +} + +@Composable +private fun FileManagerPage( + contentPadding: PaddingValues, + bottomInnerPadding: Dp, + loading: Boolean, + isRefreshing: Boolean, + errorText: String?, + displayedEntries: List, + pullToRefreshState: PullToRefreshState, + listState: LazyListState, + layoutDirection: LayoutDirection, + onRefresh: () -> Unit, + onOpenEntry: (RemoteFileEntry) -> Unit, + onShowEntryDetails: (RemoteFileEntry) -> Unit, +) { + val fileCardMinWidth = 220.dp + val listHorizontalPadding = + contentPadding.calculateLeftPadding(layoutDirection) + + contentPadding.calculateRightPadding(layoutDirection) + + UiSpacing.PageHorizontal * 2 + + PullToRefresh( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + pullToRefreshState = pullToRefreshState, + refreshTexts = listOf("下拉刷新", "释放刷新", "正在刷新...", "刷新完成"), + contentPadding = PaddingValues(top = contentPadding.calculateTopPadding() + 12.dp), + ) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + val availableListWidth = + (maxWidth - listHorizontalPadding).coerceAtLeast(fileCardMinWidth) + val columns = ((availableListWidth.value + UiSpacing.PageItem.value) / + (fileCardMinWidth.value + UiSpacing.PageItem.value)).toInt().coerceAtLeast(1) + val fileRows = remember(displayedEntries, columns) { displayedEntries.chunked(columns) } + + @Composable + fun FileStateContent() { + when { + loading -> FileManagerStatusCard( + message = "加载中", + modifier = Modifier.fillMaxWidth() + ) + + errorText != null -> FileManagerStatusCard( + message = "加载失败: $errorText", + modifier = Modifier.fillMaxWidth() + ) + + displayedEntries.isEmpty() -> FileManagerStatusCard( + message = "空目录", + modifier = Modifier.fillMaxWidth() + ) + } + } + + LazyColumn( + contentPadding = contentPadding, + bottomInnerPadding = bottomInnerPadding, + state = listState, + limitLandscapeWidth = false, ) { - BoxWithConstraints(Modifier.fillMaxWidth()) { - val fileCardMinWidth = 220.dp - val listHorizontalPadding = - pagePadding.calculateLeftPadding(layoutDirection) + - pagePadding.calculateRightPadding(layoutDirection) + - UiSpacing.PageHorizontal * 2 - val availableListWidth = - (maxWidth - listHorizontalPadding).coerceAtLeast(fileCardMinWidth) - val columns = ( - (availableListWidth.value + UiSpacing.PageItem.value) / - (fileCardMinWidth.value + UiSpacing.PageItem.value) - ).toInt().coerceAtLeast(1) - val fileRows = remember(displayedEntries, columns) { - displayedEntries.chunked(columns) - } - - @Composable - fun FileStateContent() { - when { - loading && cachedEntries == null -> - FileManagerStatusCard( - message = "加载中", - modifier = Modifier.fillMaxWidth(), - ) - - errorText != null && cachedEntries == null -> - FileManagerStatusCard( - message = "加载失败: $errorText", - modifier = Modifier.fillMaxWidth(), - ) - - displayedEntries.isEmpty() -> - FileManagerStatusCard( - message = "空目录", - modifier = Modifier.fillMaxWidth(), - ) - } - } - - LazyColumn( - contentPadding = pagePadding, - bottomInnerPadding = bottomInnerPadding, - state = listState, - limitLandscapeWidth = false, - ) { - if (loading && cachedEntries == null || - errorText != null && cachedEntries == null || - displayedEntries.isEmpty() + if (loading || errorText != null || displayedEntries.isEmpty()) { + item { FileStateContent() } + } else { + items(fileRows) { rowEntries -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.PageItem) ) { - item { FileStateContent() } - } else { - items(fileRows) { rowEntries -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.PageItem), - ) { - rowEntries.forEach { entry -> - FileManagerItemCard( - entry = entry, - summary = FileManagerService.formatSummary(entry), - onClick = { openEntry(entry) }, - onLongClick = { showEntryDetails(entry) }, - modifier = Modifier - .weight(1f) - .height(72.dp), - ) - } - repeat(columns - rowEntries.size) { - Box(Modifier.weight(1f)) - } - } + rowEntries.forEach { entry -> + FileManagerItemCard( + entry = entry, + summary = FileManagerService.formatSummary(entry), + onClick = { onOpenEntry(entry) }, + onLongClick = { onShowEntryDetails(entry) }, + modifier = Modifier + .weight(1f) + .height(72.dp), + ) } + repeat(columns - rowEntries.size) { Box(Modifier.weight(1f)) } } } } } - - val entry = selectedEntry - if (entry != null || showDetailsSheet) { - FileDetailsBottomSheet( - show = showDetailsSheet, - content = when { - detailLoading -> "正在加载详情" - entry != null && selectedStat != null -> buildDetailsText( - stat = selectedStat!!, - targetStat = selectedTargetStat, - directorySnapshot = - if (entry.isDirectory) selectedSnapshot - else null, - showRaw = showRawDetails, - ) - - else -> "暂无详情" - }, - onDismissRequest = ::dismissDetails, - onDismissFinished = ::clearDetails, - onToggleRaw = { showRawDetails = !showRawDetails }, - showingRaw = showRawDetails, - onDownload = { entry?.let(::requestDownload) }, - downloadEnabled = entry != null - && !detailLoading - && (!entry.isDirectory || selectedSnapshot != null), - ) - } - - PathJumpDialog( - show = showPathDialog, - path = pathInput, - onPathChange = { pathInput = it }, - onDismissRequest = { showPathDialog = false }, - onConfirm = { - showPathDialog = false - jumpToPath(pathInput) - }, - ) - - CreateFolderDialog( - show = showCreateFolderDialog, - folderName = newFolderName, - onFolderNameChange = { newFolderName = it }, - onDismissRequest = { showCreateFolderDialog = false }, - onConfirm = { - showCreateFolderDialog = false - val folderName = newFolderName.trim() - if (folderName.isBlank()) - snackbar.show("文件夹名称不能为空") - else taskScope.launch { - val result = runCatching { - FileManagerService.createDirectory(currentPath, folderName) - } - withContext(Dispatchers.Main) { - result.onSuccess { - snackbar.show("已创建文件夹") - directoryCache.remove(currentPath) - isRefreshing = true - }.onFailure { error -> - snackbar.show("创建失败: ${error.message ?: error.javaClass.simpleName}") - } - } - } - }, - ) } } } @@ -854,22 +568,20 @@ private fun FileDetailsBottomSheet( show: Boolean, content: String, onDismissRequest: () -> Unit, - onDismissFinished: (() -> Unit)? = null, + onDismissFinished: () -> Unit, onToggleRaw: () -> Unit, showingRaw: Boolean, onDownload: () -> Unit, downloadEnabled: Boolean, - title: String = "详细信息", ) { OverlayBottomSheet( show = show, - title = title, - defaultWindowInsetsPadding = false, + title = "文件详情", onDismissRequest = onDismissRequest, onDismissFinished = onDismissFinished, startAction = { IconButton( - onClick = onToggleRaw, + onClick = onToggleRaw ) { Icon( imageVector = @@ -896,7 +608,7 @@ private fun FileDetailsBottomSheet( LazyColumn( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(2f / 3f), + .fillMaxHeight(2f / 3f) ) { item { TextField( @@ -925,28 +637,21 @@ private fun PathJumpDialog( defaultWindowInsetsPadding = false, onDismissRequest = onDismissRequest, ) { - Column( - verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), - ) { + Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical)) { TextField( value = path, onValueChange = onPathChange, label = "/storage/emulated/0", useLabelAsPlaceholder = true, ) - Row( - horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), - ) { + Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.PageItem)) { TextButton( text = "取消", onClick = onDismissRequest, - modifier = Modifier.weight(1f), ) TextButton( text = "确定", onClick = onConfirm, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.textButtonColorsPrimary(), ) } } @@ -967,28 +672,21 @@ private fun CreateFolderDialog( defaultWindowInsetsPadding = false, onDismissRequest = onDismissRequest, ) { - Column( - verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), - ) { + Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical)) { TextField( value = folderName, onValueChange = onFolderNameChange, label = "新建文件夹", useLabelAsPlaceholder = true, ) - Row( - horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), - ) { + Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.PageItem)) { TextButton( text = "取消", onClick = onDismissRequest, - modifier = Modifier.weight(1f), ) TextButton( - text = "确定", + text = "创建", onClick = onConfirm, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.textButtonColorsPrimary(), ) } } @@ -998,91 +696,8 @@ private fun CreateFolderDialog( private fun iconForEntry(entry: RemoteFileEntry): ImageVector = when (entry.kind) { RemoteFileKind.Directory -> Icons.Rounded.Folder RemoteFileKind.Image -> Icons.Rounded.Image - RemoteFileKind.Video -> Icons.Rounded.Movie - RemoteFileKind.Audio -> Icons.Rounded.AudioFile - RemoteFileKind.Archive -> Icons.Rounded.Archive - RemoteFileKind.Apk -> Icons.Rounded.Android - RemoteFileKind.Text -> Icons.Rounded.Description RemoteFileKind.Link -> Icons.Rounded.Link - RemoteFileKind.Other -> Icons.AutoMirrored.Rounded.InsertDriveFile -} - -private fun buildPathStack(path: String): List { - val normalized = normalizePath(path) - if (normalized == ROOT_REMOTE_PATH) return listOf(ROOT_REMOTE_PATH) - - val parts = normalized.trim('/').split('/').filter { it.isNotBlank() } - val stack = mutableListOf(ROOT_REMOTE_PATH) - var current = "" - parts.forEach { part -> - current += "/$part" - stack += current - } - return stack -} - -private fun normalizePath(path: String): String { - val trimmed = path.trim() - if (trimmed.isBlank()) { - return ROOT_REMOTE_PATH - } - return "/" + trimmed - .trim('/') - .split('/') - .filter { it.isNotBlank() } - .joinToString("/") - .ifBlank { ROOT_REMOTE_PATH.removePrefix("/") } -} - -private fun sortEntries( - entries: List, - field: FileManagerSortField, - descending: Boolean, -): List { - val contentComparator = when (field) { - FileManagerSortField.NAME - -> compareBy { it.name.lowercase() } - - FileManagerSortField.SIZE - -> compareBy { it.sizeBytes ?: -1L } - .thenBy { it.name.lowercase() } - - FileManagerSortField.TIME - -> compareBy { it.modifiedAt } - .thenBy { it.name.lowercase() } - - FileManagerSortField.EXTENSION - -> compareBy { extensionSortBucket(it) } - .thenBy { extensionSortKey(it) } - .thenBy { it.name.lowercase() } - } - if (field == FileManagerSortField.EXTENSION) { - val extensionComparator = - if (descending) - compareBy { extensionSortBucket(it) } - .then(compareByDescending { extensionSortKey(it) }) - .thenBy { it.name.lowercase() } - else contentComparator - return entries.sortedWith(extensionComparator) - } - val orderComparator = - if (descending) contentComparator.reversed() - else contentComparator - return entries.sortedWith( - compareByDescending { it.isDirectory } - .then(orderComparator) - ) -} - -private fun resolveLinkTarget(entry: RemoteFileEntry): String? { - val target = entry.symlinkTarget?.trim()?.takeIf { it.isNotBlank() } ?: return null - return if (target.startsWith("/")) normalizePath(target) - else normalizePath(entry.fullPath.substringBeforeLast('/', "") + "/" + target) -} - -private fun isDirectoryStat(stat: RemoteFileStat): Boolean { - return stat.permissions?.startsWith("d") == true - || stat.typeLabel?.contains("directory", ignoreCase = true) == true + else -> Icons.AutoMirrored.Rounded.InsertDriveFile } private fun buildDetailsText( @@ -1104,12 +719,3 @@ private fun buildDetailsText( } return details.toString() } - -private fun extensionSortBucket(entry: RemoteFileEntry): Int { - return if (entry.isDirectory || extensionSortKey(entry).isEmpty()) 0 else 1 -} - -private fun extensionSortKey(entry: RemoteFileEntry): String { - if (entry.isDirectory) return "" - return entry.name.substringAfterLast('.', "").lowercase() -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerViewModel.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerViewModel.kt new file mode 100644 index 0000000..a464868 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerViewModel.kt @@ -0,0 +1,486 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.github.miuzarte.scrcpyforandroid.services.DirectoryDownloadSnapshot +import io.github.miuzarte.scrcpyforandroid.services.DirectorySnapshotSession +import io.github.miuzarte.scrcpyforandroid.services.FileManagerService +import io.github.miuzarte.scrcpyforandroid.services.RemoteFileEntry +import io.github.miuzarte.scrcpyforandroid.services.RemoteFileKind +import io.github.miuzarte.scrcpyforandroid.services.RemoteFileStat +import io.github.miuzarte.scrcpyforandroid.storage.BundleSyncDelegate +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +private const val ROOT_REMOTE_PATH = "/" +private const val INITIAL_REMOTE_PATH = "/storage/emulated/0" + +internal enum class FileManagerSortField { NAME, SIZE, TIME, EXTENSION } + +internal data class FileManagerScrollPosition(val index: Int, val offset: Int) + +internal sealed interface PendingTreeDownload { + data class File(val remotePath: String, val fileName: String) : PendingTreeDownload + data class Directory(val snapshot: DirectoryDownloadSnapshot) : PendingTreeDownload +} + +internal class FileManagerViewModel : ViewModel() { + + private val asBundleSync = BundleSyncDelegate( + sharedFlow = appSettings.bundleState, + save = { appSettings.saveBundle(it) }, + scope = viewModelScope, + ) + private val asBundle = asBundleSync.value + + private val _pathStack = MutableStateFlow(buildPathStack(INITIAL_REMOTE_PATH)) + val pathStack: StateFlow> = _pathStack.asStateFlow() + val currentPath: StateFlow = _pathStack + .map { it.lastOrNull() ?: INITIAL_REMOTE_PATH } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + INITIAL_REMOTE_PATH, + ) + val canNavigateUp: StateFlow = _pathStack + .map { it.size > 1 } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + false, + ) + + private val _directoryCache = MutableStateFlow>>(emptyMap()) + val directoryCache: StateFlow>> = + _directoryCache.asStateFlow() + + val cachedEntries: StateFlow?> = combine( + _directoryCache, currentPath, + ) { cache, path -> cache[path] } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + + val sortField: StateFlow = asBundle + .map { + runCatching { FileManagerSortField.valueOf(it.fileManagerSortBy) }.getOrDefault( + FileManagerSortField.NAME + ) + } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + FileManagerSortField.NAME, + ) + val sortDescending: StateFlow = asBundle + .map { it.fileManagerSortDescending } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + false, + ) + + val displayedEntries: StateFlow> = combine( + cachedEntries, sortField, sortDescending, + ) { entries, field, descending -> + sortEntries(entries.orEmpty(), field, descending) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + emptyList(), + ) + + private val _directoryScrollCache = + MutableStateFlow(emptyMap()) + val directoryScrollCache: StateFlow> = + _directoryScrollCache.asStateFlow() + + private val _loading = MutableStateFlow(false) + val loading: StateFlow = _loading.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + private val _errorText = MutableStateFlow(null) + val errorText: StateFlow = _errorText.asStateFlow() + + private val _showDetailsSheet = MutableStateFlow(false) + val showDetailsSheet: StateFlow = _showDetailsSheet.asStateFlow() + + private val _showRawDetails = MutableStateFlow(false) + val showRawDetails: StateFlow = _showRawDetails.asStateFlow() + + private val _detailLoading = MutableStateFlow(false) + val detailLoading: StateFlow = _detailLoading.asStateFlow() + + private val _selectedEntry = MutableStateFlow(null) + val selectedEntry: StateFlow = _selectedEntry.asStateFlow() + + private val _selectedStat = MutableStateFlow(null) + val selectedStat: StateFlow = _selectedStat.asStateFlow() + + private val _selectedTargetStat = MutableStateFlow(null) + val selectedTargetStat: StateFlow = _selectedTargetStat.asStateFlow() + + private val _selectedSnapshot = MutableStateFlow(null) + val selectedSnapshot: StateFlow = _selectedSnapshot.asStateFlow() + + private var activeSnapshotSession: DirectorySnapshotSession? = null + + private val _pendingTreeDownload = MutableStateFlow(null) + val pendingTreeDownload: StateFlow = _pendingTreeDownload.asStateFlow() + + private val _snackbarEvents = Channel(Channel.BUFFERED) + val snackbarEvents: Flow = _snackbarEvents.receiveAsFlow() + + private fun snackbar(msg: String) { + _snackbarEvents.trySend(msg) + } + + init { + asBundleSync.start() + } + + override fun onCleared() { + clearDetailsInternal() + runBlocking(Dispatchers.IO) { asBundleSync.flush() } + } + + fun navigateUp(): Boolean { + val stack = _pathStack.value + if (stack.size <= 1) return false + _pathStack.update { it.dropLast(1) } + return true + } + + fun jumpToPath(rawPath: String) { + val normalized = normalizePath(rawPath) + _pathStack.value = buildPathStack(normalized) + } + + fun openEntry(entry: RemoteFileEntry) { + when { + entry.isDirectory -> { + _pathStack.update { it + normalizePath(entry.fullPath) } + } + + entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> { + viewModelScope.launch { + val targetPath = resolveLinkTarget(entry) + if (targetPath == null) { + snackbar("链接目标不可用,长按查看信息") + return@launch + } + val result = runCatching { FileManagerService.stat(targetPath) } + withContext(Dispatchers.Main) { + result.onSuccess { targetStat -> + if (isDirectoryStat(targetStat)) jumpToPath(targetPath) + else snackbar("链接目标不是文件夹,长按查看信息") + }.onFailure { error -> + snackbar("读取链接目标失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + } + + else -> snackbar("长按可查看文件详情") + } + } + + suspend fun reloadCurrentDirectory(force: Boolean) { + val path = currentPath.value + val cached = _directoryCache.value[path] + if (!force && cached != null) return + _loading.value = cached == null + _errorText.value = null + val result = runCatching { FileManagerService.listDirectory(path) } + _loading.value = false + _isRefreshing.value = false + result.onSuccess { entries -> _directoryCache.update { cache -> cache + (path to entries) } } + .onFailure { _errorText.value = it.message ?: it.javaClass.simpleName } + } + + fun invalidateCacheForCurrentDirectory() { + _directoryCache.update { it - currentPath.value } + } + + fun createFolder(folderName: String) { + val name = folderName.trim() + if (name.isBlank()) { + snackbar("文件夹名称不能为空") + return + } + viewModelScope.launch { + val result = runCatching { + FileManagerService.createDirectory(currentPath.value, name) + } + withContext(Dispatchers.Main) { + result.onSuccess { + snackbar("已创建文件夹") + invalidateCacheForCurrentDirectory() + _isRefreshing.value = true + }.onFailure { error -> + snackbar("创建失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + } + + fun uploadFile(context: Context, uri: Uri) { + viewModelScope.launch { + val result = runCatching { + context.contentResolver + .takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + FileManagerService.uploadFile(context, uri, currentPath.value) + } + withContext(Dispatchers.Main) { + result.onSuccess { + snackbar("已上传到 ${currentPath.value}") + invalidateCacheForCurrentDirectory() + _isRefreshing.value = true + }.onFailure { error -> + snackbar("上传失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + } + + fun showEntryDetails(entry: RemoteFileEntry) { + clearDetailsInternal() + _selectedEntry.value = entry + _showDetailsSheet.value = true + _detailLoading.value = true + viewModelScope.launch { + val statResult = runCatching { FileManagerService.stat(entry.fullPath) } + val linkTargetPath = resolveLinkTarget(entry) + val targetStatResult = + linkTargetPath?.let { runCatching { FileManagerService.stat(it) } } + val snapshotResult = + if (entry.isDirectory) runCatching { + val session = DirectorySnapshotSession.open() + activeSnapshotSession = session + session.load(entry.fullPath) + } + else null + + withContext(Dispatchers.Main) { + _detailLoading.value = false + statResult + .onSuccess { _selectedStat.value = it } + .onFailure { error -> + snackbar("读取详情失败: ${error.message ?: error.javaClass.simpleName}") + if (_selectedEntry.value === entry) _selectedEntry.value = null + } + targetStatResult + ?.onSuccess { _selectedTargetStat.value = it } + snapshotResult + ?.onSuccess { _selectedSnapshot.value = it } + ?.onFailure { error -> + snackbar("目录扫描失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + } + + fun dismissDetails() { + _showDetailsSheet.value = false + } + + fun toggleRawDetails() { + _showRawDetails.update { !it } + } + + fun clearDetails() { + clearDetailsInternal() + _showDetailsSheet.value = false + } + + private fun clearDetailsInternal() { + _selectedEntry.value = null + _selectedStat.value = null + _selectedTargetStat.value = null + _selectedSnapshot.value = null + _detailLoading.value = false + _showRawDetails.value = false + val session = activeSnapshotSession + activeSnapshotSession = null + if (session != null) { + viewModelScope.launch { runCatching { session.interrupt() } } + } + } + + fun requestDownload(entry: RemoteFileEntry) { + val snapshot = _selectedSnapshot.value + dismissDetails() + snackbar("开始下载") + viewModelScope.launch { + if (entry.isDirectory) { + if (snapshot == null) { + snackbar("目录信息仍在加载,请稍后重试") + return@launch + } + val directSaved = FileManagerService.downloadDirectoryToPublicDownloads(snapshot) + if (directSaved) + snackbar("已下载到 Download/Scrcpy") + else _pendingTreeDownload.value = PendingTreeDownload.Directory(snapshot) + } else { + val directSaved = + FileManagerService.downloadFileToPublicDownloads(entry.fullPath, entry.name) + if (directSaved) snackbar("已下载到 Download/Scrcpy") + else _pendingTreeDownload.value = + PendingTreeDownload.File(entry.fullPath, entry.name) + } + if (_pendingTreeDownload.value != null) + snackbar("无法直接写入 Download/Scrcpy,请选择保存目录") + } + } + + suspend fun startDownloadToTree(context: Context, treeUri: Uri, request: PendingTreeDownload) { + when (request) { + is PendingTreeDownload.File -> FileManagerService.downloadFileToTree( + context = context, + treeUri = treeUri, + remotePath = request.remotePath, + fileName = request.fileName, + ) + + is PendingTreeDownload.Directory -> FileManagerService.downloadDirectoryToTree( + context = context, treeUri = treeUri, snapshot = request.snapshot, + ) + } + } + + fun consumePendingTreeDownload() { + val pending = _pendingTreeDownload.value + _pendingTreeDownload.value = null + } + + fun restorePendingTreeDownload(request: PendingTreeDownload) { + _pendingTreeDownload.value = request + } + + fun saveScrollPosition(path: String, index: Int, offset: Int) { + _directoryScrollCache.update { it + (path to FileManagerScrollPosition(index, offset)) } + } + + fun setRefreshing(value: Boolean) { + _isRefreshing.value = value + } + + fun downloadToTree(context: Context, treeUri: Uri) { + val request = _pendingTreeDownload.value + _pendingTreeDownload.value = null + if (request == null) return + viewModelScope.launch { + val result = runCatching { + context.contentResolver + .takePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + startDownloadToTree(context, treeUri, request) + } + withContext(Dispatchers.Main) { + result + .onSuccess { snackbar("下载完成") } + .onFailure { error -> snackbar("下载失败: ${error.message ?: error.javaClass.simpleName}") } + } + } + } + + fun updateSort(sortBy: FileManagerSortField? = null, descending: Boolean? = null) { + viewModelScope.launch { + appSettings.updateBundle { + it.copy( + fileManagerSortBy = (sortBy ?: sortField.value).name, + fileManagerSortDescending = descending ?: sortDescending.value, + ) + } + } + } +} + +internal fun buildPathStack(path: String): List { + val normalized = normalizePath(path) + if (normalized == ROOT_REMOTE_PATH) return listOf(ROOT_REMOTE_PATH) + val parts = normalized.trim('/').split('/').filter { it.isNotBlank() } + val stack = mutableListOf(ROOT_REMOTE_PATH) + var current = "" + parts.forEach { part -> + current += "/$part" + stack += current + } + return stack +} + +internal fun normalizePath(path: String): String { + val trimmed = path.trim() + if (trimmed.isBlank()) return ROOT_REMOTE_PATH + return "/" + trimmed + .trim('/') + .split('/') + .filter { it.isNotBlank() } + .joinToString("/") + .ifBlank { ROOT_REMOTE_PATH.removePrefix("/") } +} + +internal fun sortEntries( + entries: List, + field: FileManagerSortField, + descending: Boolean, +): List { + val comparator: Comparator = when (field) { + FileManagerSortField.NAME -> compareBy { it.name.lowercase() } + FileManagerSortField.SIZE -> compareBy { + it.sizeBytes ?: -1L + }.thenBy { it.name.lowercase() } + + FileManagerSortField.TIME -> compareBy { it.modifiedAt }.thenBy { it.name.lowercase() } + FileManagerSortField.EXTENSION -> compareBy(::extensionSortBucket) + .thenBy(::extensionSortKey).thenBy { it.name.lowercase() } + } + return entries.sortedWith( + compareByDescending { it.isDirectory } + .then(if (descending) comparator.reversed() else comparator) + ) +} + +internal fun resolveLinkTarget(entry: RemoteFileEntry): String? { + val target = entry.symlinkTarget?.trim()?.takeIf { it.isNotBlank() } ?: return null + return if (target.startsWith("/")) normalizePath(target) + else normalizePath(entry.fullPath.substringBeforeLast('/', "") + "/" + target) +} + +internal fun isDirectoryStat(stat: RemoteFileStat): Boolean = + stat.permissions + ?.startsWith("d") == true || + stat.typeLabel + ?.contains("directory", ignoreCase = true) == true + +private fun extensionSortBucket(entry: RemoteFileEntry) = + if (entry.isDirectory || extensionSortKey(entry).isEmpty()) 0 else 1 + +private fun extensionSortKey(entry: RemoteFileEntry) = + if (entry.isDirectory) "" + else entry.name.substringAfterLast('.', "").lowercase() + 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 baa2b0a..1d84d43 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 @@ -56,6 +56,7 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler +import io.github.miuzarte.scrcpyforandroid.services.LocalInputService import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.Settings @@ -267,21 +268,6 @@ fun FullscreenControlScreen( } } - suspend fun sendKeycode(keycode: Int) { - runCatching { - withContext(Dispatchers.IO) { - scrcpy.injectKeycode(0, keycode) - scrcpy.injectKeycode(1, keycode) - } - }.onFailure { e -> - Log.w( - "FullscreenControlPage", - "sendKeycode failed for keycode=$keycode", - e - ) - } - } - suspend fun sendBackOrTurnScreenOn() { runCatching { withContext(Dispatchers.IO) { @@ -295,9 +281,7 @@ fun FullscreenControlScreen( BackHandler(enabled = true) { if (asBundle.fullscreenControlBackToDevice && currentSession != null) - taskScope.launch { - sendBackOrTurnScreenOn() - } + taskScope.launch { sendBackOrTurnScreenOn() } else onBack() } @@ -323,34 +307,6 @@ fun FullscreenControlScreen( } } - 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 = scrcpy, @@ -365,6 +321,78 @@ fun FullscreenControlScreen( } } + fun handleButtonAction(action: VirtualButtonAction) { + when (action) { + VirtualButtonAction.RECENT_TASKS -> { + showRecentTasksSheet = true + if (recentTasks.isEmpty() && !listingsRefreshBusy) { + taskScope.launch { + refreshApps() + refreshRecentTasks() + } + } + } + + VirtualButtonAction.ALL_APPS -> { + showAllAppsSheet = true + if (apps.isEmpty() && !listingsRefreshBusy) { + taskScope.launch { + refreshApps() + } + } + } + + VirtualButtonAction.TOGGLE_IME -> imeRequestToken++ + + VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> + taskScope.launch { + val session = currentSession ?: return@launch + val text = LocalInputService.getClipboardText(activity ?: return@launch) + ?.takeIf { it.isNotBlank() } + if (text == null) { + snackbar.show("本机剪贴板为空或不是文本") + return@launch + } + val useLegacyPaste = session.legacyPaste + runCatching { + withContext(Dispatchers.IO) { + if (useLegacyPaste) scrcpy.injectText(text) + else scrcpy.setClipboard(text, paste = true) + } + }.onFailure { error -> + Log.w("FullscreenControl", "pasteLocalClipboard failed", error) + snackbar.show(if (useLegacyPaste) "legacy 粘贴失败" else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste") + } + } + + else -> action.keycode?.let { + taskScope.launch { + runCatching { + withContext(Dispatchers.IO) { + scrcpy.injectKeycode(0, it) + scrcpy.injectKeycode(1, it) + } + }.onFailure { e -> + Log.w( + "FullscreenControlPage", + "sendKeycode failed for keycode=$it", + e + ) + } + } + } + } + } + + suspend fun startApp(packageName: String) = + runCatching { + withContext(Dispatchers.IO) { + scrcpy.startApp(packageName) + } + }.onFailure { error -> + snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}") + } + Scaffold( contentWindowInsets = WindowInsets(0, 0, 0, 0), snackbarHost = { SnackbarHost(snackbar.hostState) }, @@ -402,9 +430,7 @@ fun FullscreenControlScreen( } }, onBackOrScreenOn = { action -> - withContext(Dispatchers.IO) { - scrcpy.pressBackOrTurnScreenOn(action) - } + withContext(Dispatchers.IO) { scrcpy.pressBackOrTurnScreenOn(action) } }, ) @@ -421,44 +447,9 @@ fun FullscreenControlScreen( dock = fullscreenVirtualButtonDock, reverseOrder = fullscreenVirtualButtonReverseOrder, thickness = fullscreenVirtualButtonHeight, - onAction = { action -> - 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) { - null - } else { - { onDismissRequest -> - PasswordPickerPopupContent( - onDismissRequest = onDismissRequest, - ) - } + onAction = ::handleButtonAction, + passwordPopupContent = fragmentActivity?.let { + { onDismissRequest -> PasswordPickerPopupContent(onDismissRequest = onDismissRequest) } }, ) } @@ -467,43 +458,10 @@ fun FullscreenControlScreen( bar.FloatingBall( actions = floatingActions, modifier = Modifier.fillMaxSize(), - onAction = { action -> - 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) } - } - } + onAction = ::handleButtonAction, + passwordPopupContent = fragmentActivity?.let { + { onDismissRequest -> PasswordPickerPopupContent(onDismissRequest = onDismissRequest) } }, - passwordPopupContent = - if (fragmentActivity == null) null - else { onDismissRequest -> - PasswordPickerPopupContent( - onDismissRequest = onDismissRequest, - ) - }, ) } @@ -521,15 +479,7 @@ fun FullscreenControlScreen( 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}") - } - } + taskScope.launch { startApp(task.packageName) } }, ) }, @@ -556,15 +506,7 @@ fun FullscreenControlScreen( 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}") - } - } + taskScope.launch { startApp(app.packageName) } }, ) }, @@ -576,7 +518,6 @@ fun FullscreenControlScreen( } }, ) - } } } @@ -676,22 +617,6 @@ private fun rotateDockDirection( else -> direction } -/** - * FullscreenControlScreen - * - * Purpose: - * - Presents a fullscreen interactive touch surface that maps Compose touch events - * to device coordinates and injects them via [onInjectTouch]. - * - Responsible for pointer tracking, multi-touch mapping, coordinate normalization, - * and lifetime of synthetic touch events sent to the device. - * - * Concurrency and side-effects: - * - All heavy computations are local to the UI thread; injection itself is a quick - * callback (`onInjectTouch`) which delegates to native code elsewhere — keep that - * callback lightweight. - * - Use `pointerInteropFilter` to receive raw MotionEvent instances for precise - * multi-touch handling and to map Android pointer IDs to device pointers. - */ @Composable fun FullscreenControlPage( scrcpy: Scrcpy, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt index e605cd9..a8e09ec 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -314,6 +314,11 @@ fun MainScreen() { autoReconnectManager = autoReconnectManager, ) } + + val deviceTabViewModelFactory = remember(scrcpy, deviceConnectionServices) { + DeviceTabViewModel.Factory(scrcpy, deviceConnectionServices) + } + DisposableEffect(deviceConnectionServices) { onDispose { deviceConnectionServices.autoReconnectManager.close() @@ -568,9 +573,8 @@ fun MainScreen() { saveableStateHolder.SaveableStateProvider(tab.name) { when (tab) { MainBottomTabDestination.Devices -> DeviceTabScreen( + viewModelFactory = deviceTabViewModelFactory, scrollBehavior = devicesPageScrollBehavior, - scrcpy = scrcpy, - connectionServices = deviceConnectionServices, bottomInnerPadding = bottomInnerPadding, onOpenReorderDevices = { showReorderDevices = true }, onPreviewGestureLockChanged = { locked -> 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 face8ad..dfb46be 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 @@ -46,13 +46,14 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.miuix.SpinnerEntry 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.OverlaySpinnerWithFallback 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 import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy @@ -92,7 +93,6 @@ 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.SnackbarHost -import top.yukonga.miuix.kmp.basic.SpinnerEntry import top.yukonga.miuix.kmp.basic.TabRow import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextButton @@ -105,7 +105,6 @@ import top.yukonga.miuix.kmp.icon.extended.Store import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet import top.yukonga.miuix.kmp.overlay.OverlayDialog import top.yukonga.miuix.kmp.overlay.OverlayListPopup -import top.yukonga.miuix.kmp.preference.ArrowPreference import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference import top.yukonga.miuix.kmp.preference.SwitchPreference import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme @@ -257,9 +256,10 @@ internal fun ScrcpyAllOptionsScreen( onDismissRequest = { showProfileMenu = false }, ) { ListPopupColumn { - ProfileMenuPopupItem( + DropdownImpl( text = "管理配置", optionSize = 1, + isSelected = false, index = 0, onSelectedIndexChange = { showManageProfilesSheet = true @@ -516,9 +516,7 @@ internal fun ScrcpyAllOptionsPage( (displays.indexOfFirst { it.id == soBundle.displayId } + 1).coerceAtLeast(0) } val displayOverrideEndActionValue = remember( - displays, - listRefreshVersion, - soBundle.displayId, + displays, listRefreshVersion, soBundle.displayId, ) { if (displays.isEmpty() && soBundle.displayId >= 0) soBundle.displayId.toString() else null @@ -550,9 +548,7 @@ internal fun ScrcpyAllOptionsPage( (cameras.indexOfFirst { it.id == soBundle.cameraId } + 1).coerceAtLeast(0) } val cameraOverrideEndActionValue = remember( - cameras, - listRefreshVersion, - soBundle.cameraId, + cameras, listRefreshVersion, soBundle.cameraId, ) { if (cameras.isEmpty() && soBundle.cameraId.isNotBlank()) soBundle.cameraId else null @@ -601,11 +597,8 @@ internal fun ScrcpyAllOptionsPage( } } val cameraSizeOverrideEndActionValue = remember( - cameraSizes, - listRefreshVersion, - soBundle.cameraSize, - soBundle.cameraSizeCustom, - soBundle.cameraSizeUseCustom, + cameraSizes, listRefreshVersion, + soBundle.cameraSize, soBundle.cameraSizeCustom, soBundle.cameraSizeUseCustom, ) { when { cameraSizes.isNotEmpty() -> null @@ -689,9 +682,7 @@ internal fun ScrcpyAllOptionsPage( .coerceAtLeast(0) } val videoEncoderOverrideEndActionValue = remember( - videoEncoders, - listRefreshVersion, - soBundle.videoEncoder, + videoEncoders, listRefreshVersion, soBundle.videoEncoder, ) { if (videoEncoders.isEmpty() && soBundle.videoEncoder.isNotBlank()) soBundle.videoEncoder else null @@ -722,9 +713,7 @@ internal fun ScrcpyAllOptionsPage( .coerceAtLeast(0) } val audioEncoderOverrideEndActionValue = remember( - audioEncoders, - listRefreshVersion, - soBundle.audioEncoder, + audioEncoders, listRefreshVersion, soBundle.audioEncoder, ) { if (audioEncoders.isEmpty() && soBundle.audioEncoder.isNotBlank()) soBundle.audioEncoder else null @@ -792,13 +781,12 @@ internal fun ScrcpyAllOptionsPage( } } val startAppOverrideEndActionValue = remember( - apps, - listRefreshVersion, - soBundle.startApp, + apps, listRefreshVersion, soBundle.startApp, ) { if (apps.isEmpty() && soBundle.startApp.isNotBlank()) soBundle.startApp else null } + var startAppCustomInput by rememberSaveable(soBundle.startAppCustom) { mutableStateOf(soBundle.startAppCustom) } @@ -1204,30 +1192,29 @@ internal fun ScrcpyAllOptionsPage( ) AnimatedVisibility(soBundle.videoSource == "display") { Column { - ArrowPreference( - title = "获取 Displays", - summary = "--list-displays", - onClick = { - if (refreshBusy) return@ArrowPreference - scope.launch { - snackbar.show("获取中") - try { - withContext(Dispatchers.IO) { - scrcpy.listings.getDisplays(forceRefresh = true) - } - snackbar.show("获取成功") - } catch (e: Exception) { - snackbar.show("刷新失败: ${e.message}") - } - } - }, - ) - SuperSpinner( + OverlaySpinnerWithFallback( title = "监视器 ID", summary = "--display-id", items = displaySpinnerItems, selectedIndex = displayDropdownIndex, + dataLoaded = displays.isNotEmpty(), + dataLoading = refreshBusy, overrideEndActionValue = displayOverrideEndActionValue, + onExpandedChange = { expanded -> + if (expanded && displays.isEmpty()) { + scope.launch { + snackbar.show("获取中") + try { + withContext(Dispatchers.IO) { + scrcpy.listings.getDisplays(forceRefresh = false) + } + snackbar.show("获取成功") + } catch (e: Exception) { + snackbar.show("刷新失败: ${e.message}") + } + } + } + }, onSelectedIndexChange = { soBundle = soBundle.copy( displayId = @@ -1301,30 +1288,29 @@ internal fun ScrcpyAllOptionsPage( } AnimatedVisibility(soBundle.videoSource == "camera") { Column { - ArrowPreference( - title = "获取 Cameras", - summary = "--list-cameras", - onClick = { - if (refreshBusy) return@ArrowPreference - scope.launch { - snackbar.show("获取中") - try { - withContext(Dispatchers.IO) { - scrcpy.listings.getCameras(forceRefresh = true) - } - snackbar.show("获取成功") - } catch (e: Exception) { - snackbar.show("刷新失败: ${e.message}") - } - } - }, - ) - SuperSpinner( + OverlaySpinnerWithFallback( title = "摄像头 ID", summary = "--camera-id", items = cameraSpinnerItems, selectedIndex = cameraDropdownIndex, + dataLoaded = cameras.isNotEmpty(), + dataLoading = refreshBusy, overrideEndActionValue = cameraOverrideEndActionValue, + onExpandedChange = { expanded -> + if (expanded && cameras.isEmpty()) { + scope.launch { + snackbar.show("获取中") + try { + withContext(Dispatchers.IO) { + scrcpy.listings.getCameras(forceRefresh = false) + } + snackbar.show("获取成功") + } catch (e: Exception) { + snackbar.show("刷新失败: ${e.message}") + } + } + } + }, onSelectedIndexChange = { soBundle = soBundle.copy( cameraId = @@ -1346,30 +1332,29 @@ internal fun ScrcpyAllOptionsPage( ) }, ) - ArrowPreference( - title = "获取 Camera Sizes", - summary = "--list-camera-sizes", - onClick = { - if (refreshBusy) return@ArrowPreference - scope.launch { - snackbar.show("获取中") - try { - withContext(Dispatchers.IO) { - scrcpy.listings.getCameraSizes(forceRefresh = true) - } - snackbar.show("获取成功") - } catch (e: Exception) { - snackbar.show("刷新失败: ${e.message}") - } - } - }, - ) - SuperSpinner( + OverlaySpinnerWithFallback( title = "摄像头分辨率", summary = "--camera-size", items = cameraSizeSpinnerItems, selectedIndex = cameraSizeDropdownIndex, + dataLoaded = cameraSizes.isNotEmpty(), + dataLoading = refreshBusy, overrideEndActionValue = cameraSizeOverrideEndActionValue, + onExpandedChange = { expanded -> + if (expanded && cameraSizes.isEmpty()) { + scope.launch { + snackbar.show("获取中") + try { + withContext(Dispatchers.IO) { + scrcpy.listings.getCameraSizes(forceRefresh = false) + } + snackbar.show("获取成功") + } catch (e: Exception) { + snackbar.show("刷新失败: ${e.message}") + } + } + } + }, onSelectedIndexChange = { when (it) { 0 -> { @@ -1524,31 +1509,29 @@ internal fun ScrcpyAllOptionsPage( item { Card { - ArrowPreference( - title = "获取编码器列表", - summary = "--list-encoders", - onClick = { - if (refreshBusy) return@ArrowPreference - scope.launch { - snackbar.show("获取中") - try { - withContext(Dispatchers.IO) { - scrcpy.listings.getEncoders(forceRefresh = true) - } - snackbar.show("获取成功") - } catch (e: Exception) { - snackbar.show("刷新失败: ${e.message}") - } - } - }, - ) - // TODO: 等 MIUIX 发版, 在 OverlaySpinnerPreference / OverlayDropdownPreference 支持展开状态回调后, 在展开时触发获取 - SuperSpinner( + OverlaySpinnerWithFallback( title = "视频编码器", summary = "--video-encoder", items = videoEncoderItems, selectedIndex = videoEncoderIndex, + dataLoaded = videoEncoders.isNotEmpty(), + dataLoading = refreshBusy, overrideEndActionValue = videoEncoderOverrideEndActionValue, + onExpandedChange = { expanded -> + if (expanded && videoEncoders.isEmpty()) { + scope.launch { + snackbar.show("获取中") + try { + withContext(Dispatchers.IO) { + scrcpy.listings.getEncoders(forceRefresh = false) + } + snackbar.show("获取成功") + } catch (e: Exception) { + snackbar.show("刷新失败: ${e.message}") + } + } + } + }, onSelectedIndexChange = { soBundle = soBundle.copy( videoEncoder = @@ -1571,12 +1554,29 @@ internal fun ScrcpyAllOptionsPage( .fillMaxWidth() .padding(all = UiSpacing.Large), ) - SuperSpinner( + OverlaySpinnerWithFallback( title = "音频编码器", summary = "--audio-encoder", items = audioEncoderItems, selectedIndex = audioEncoderIndex, + dataLoaded = audioEncoders.isNotEmpty(), + dataLoading = refreshBusy, overrideEndActionValue = audioEncoderOverrideEndActionValue, + onExpandedChange = { expanded -> + if (expanded && audioEncoders.isEmpty()) { + scope.launch { + snackbar.show("获取中") + try { + withContext(Dispatchers.IO) { + scrcpy.listings.getEncoders(forceRefresh = false) + } + snackbar.show("获取成功") + } catch (e: Exception) { + snackbar.show("刷新失败: ${e.message}") + } + } + } + }, onSelectedIndexChange = { soBundle = soBundle.copy( audioEncoder = @@ -1734,30 +1734,29 @@ internal fun ScrcpyAllOptionsPage( if (soBundle.videoSource == "display") item { Card { - ArrowPreference( - title = "获取应用列表", - summary = "--list-apps", - onClick = { - if (refreshBusy) return@ArrowPreference - scope.launch { - snackbar.show("获取中") - try { - withContext(Dispatchers.IO) { - scrcpy.listings.getApps(forceRefresh = true) - } - snackbar.show("获取成功") - } catch (e: Exception) { - snackbar.show("刷新失败: ${e.message}") - } - } - }, - ) - SuperSpinner( + OverlaySpinnerWithFallback( title = "scrcpy 启动后打开应用", summary = "--start-app", items = appDropdownItems, selectedIndex = appDropdownIndex, + dataLoaded = apps.isNotEmpty(), + dataLoading = refreshBusy, overrideEndActionValue = startAppOverrideEndActionValue, + onExpandedChange = { expanded -> + if (expanded && apps.isEmpty()) { + scope.launch { + snackbar.show("获取中") + try { + withContext(Dispatchers.IO) { + scrcpy.listings.getApps(forceRefresh = false) + } + snackbar.show("获取成功") + } catch (e: Exception) { + snackbar.show("刷新失败: ${e.message}") + } + } + } + }, onSelectedIndexChange = { when (it) { 0 -> { @@ -2112,39 +2111,6 @@ private enum class ProfileDialogMode { Rename, } -@Composable -private fun ProfileMenuPopupItem( - text: String, - optionSize: Int, - index: Int, - enabled: Boolean = true, - onSelectedIndexChange: (Int) -> Unit, -) { - if (enabled) { - DropdownImpl( - text = text, - optionSize = optionSize, - isSelected = false, - index = index, - onSelectedIndexChange = onSelectedIndexChange, - ) - return - } - - Text( - text = text, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.PopupHorizontal) - .padding( - top = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem, - bottom = if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem, - ), - color = colorScheme.disabledOnSecondaryVariant, - fontWeight = FontWeight.Medium, - ) -} - @Composable private fun ProfileNameDialog( mode: ProfileDialogMode?, 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 5e5aad0..11647af 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 @@ -51,8 +51,6 @@ import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.MonetKeyColorOptions import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop -import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdownGroup -import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdownPreference import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -60,6 +58,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.DropdownEntry +import top.yukonga.miuix.kmp.basic.DropdownItem import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.Scaffold @@ -481,12 +481,11 @@ fun SettingsPage( ) AnimatedVisibility(asBundle.showFullscreenVirtualButtons) { Column { - MultiGroupsDropdownPreference( - title = "虚拟按钮方向", - summary = fullscreenVirtualButtonDock.summary, - groups = listOf( - MultiGroupsDropdownGroup( - options = FullscreenVirtualButtonDock.modeItems, + OverlayDropdownPreference( + entries = listOf( + DropdownEntry( + items = FullscreenVirtualButtonDock + .modeItems.map { DropdownItem(it) }, selectedIndex = fullscreenVirtualButtonDock.modeIndex, onSelectedIndexChange = { modeIndex -> asBundle = asBundle.copy( @@ -499,8 +498,9 @@ fun SettingsPage( ) }, ), - MultiGroupsDropdownGroup( - options = FullscreenVirtualButtonDock.directionItems, + DropdownEntry( + items = FullscreenVirtualButtonDock + .directionItems.map { DropdownItem(it) }, selectedIndex = fullscreenVirtualButtonDock.directionIndex, onSelectedIndexChange = { directionIndex -> asBundle = asBundle.copy( @@ -514,6 +514,8 @@ fun SettingsPage( }, ), ), + title = "虚拟按钮方向", + summary = fullscreenVirtualButtonDock.summary, ) SuperSlider( title = "虚拟按钮高度", diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt index e62c2ec..de472f4 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -44,38 +43,24 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import com.termux.terminal.KeyHandler +import androidx.lifecycle.viewmodel.compose.viewModel import com.termux.terminal.TerminalSession -import com.termux.terminal.TextStyle import com.termux.view.TerminalView import com.termux.view.TerminalViewClient import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream -import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.services.LocalInputService import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController -import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.contextClick import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop -import io.github.miuzarte.scrcpyforandroid.widgets.PopupMenuItem -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext +import top.yukonga.miuix.kmp.basic.DropdownImpl import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.ListPopupColumn import top.yukonga.miuix.kmp.basic.ListPopupDefaults import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.SmallTopAppBar -import top.yukonga.miuix.kmp.basic.SnackbarDuration -import top.yukonga.miuix.kmp.basic.SnackbarResult import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.blur.layerBackdrop @@ -85,21 +70,14 @@ import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet import top.yukonga.miuix.kmp.overlay.OverlayListPopup import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import java.io.File -import java.nio.charset.StandardCharsets import kotlin.math.abs import kotlin.math.max -import kotlin.math.roundToInt -private const val DEFAULT_TERMINAL_FONT_SIZE_SP = 14f -private const val MIN_TERMINAL_FONT_SIZE_SP = 1f -private const val MAX_TERMINAL_FONT_SIZE_SP = 32f private const val FONT_SCALE_STEP_THRESHOLD = 1.08f private const val LOG_TAG = "TerminalScreen" -private const val TERMINAL_FONT_RELATIVE_PATH = "terminal/font.ttf" -private fun terminalFontFile(context: android.content.Context): File { - return File(context.filesDir, TERMINAL_FONT_RELATIVE_PATH) -} +private fun terminalFontFile(context: android.content.Context) = + File(context.filesDir, "terminal/font.ttf") private fun loadTerminalTypeface(context: android.content.Context): Typeface? { val file = terminalFontFile(context) @@ -113,6 +91,7 @@ fun TerminalScreen( isActive: Boolean, onTerminalGestureLockChanged: (Boolean) -> Unit, ) { + val viewModel: TerminalViewModel = viewModel() val context = LocalContext.current val snackbar = LocalSnackbarController.current val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current) @@ -121,6 +100,10 @@ fun TerminalScreen( var showOutputSheet by rememberSaveable { mutableStateOf(false) } var output by rememberSaveable { mutableStateOf("") } + LaunchedEffect(Unit) { + viewModel.snackbarEvents.collect { snackbar.show(it) } + } + Scaffold( topBar = { BlurredBar(backdrop = blurBackdrop) { @@ -144,9 +127,10 @@ fun TerminalScreen( onDismissRequest = { showMenu = false }, ) { ListPopupColumn { - PopupMenuItem( + DropdownImpl( text = "自由复制", optionSize = 2, + isSelected = false, index = 0, enabled = output.isNotBlank(), onSelectedIndexChange = { @@ -154,9 +138,10 @@ fun TerminalScreen( showOutputSheet = true }, ) - PopupMenuItem( + DropdownImpl( text = "清屏", optionSize = 2, + isSelected = false, index = 1, onSelectedIndexChange = { showMenu = false @@ -174,9 +159,10 @@ fun TerminalScreen( Box( modifier = if (blurActive) Modifier.layerBackdrop(blurBackdrop) - else Modifier, + else Modifier ) { TerminalPage( + viewModel = viewModel, contentPadding = pagePadding, bottomInnerPadding = bottomInnerPadding, isActive = isActive, @@ -184,7 +170,6 @@ fun TerminalScreen( output = output, onOutputChange = { output = it }, ) - TerminalOutputBottomSheet( show = showOutputSheet, output = output, @@ -202,6 +187,7 @@ fun TerminalScreen( @SuppressLint("ClickableViewAccessibility") @Composable private fun TerminalPage( + viewModel: TerminalViewModel, contentPadding: PaddingValues, bottomInnerPadding: Dp, isActive: Boolean, @@ -213,151 +199,81 @@ private fun TerminalPage( val density = LocalDensity.current val snackbar = LocalSnackbarController.current val haptic = LocalHapticFeedback.current - val asBundleShared by appSettings.bundleState.collectAsState() - val uiScope = rememberCoroutineScope() - val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } - val shellWriteMutex = remember { Mutex() } - var terminalFontSizeSp by rememberSaveable(asBundleShared.terminalFontSizeSp) { - mutableFloatStateOf(asBundleShared.terminalFontSizeSp) - } - var shellReady by remember { mutableStateOf(false) } - var shellConnecting by remember { mutableStateOf(false) } - var shellStream by remember { mutableStateOf(null) } - var terminalView by remember { mutableStateOf(null) } + + val asBundle by viewModel.asBundle.collectAsState() + val terminalFontSizeSp by viewModel.terminalFontSizeSp.collectAsState() + val imeBottomDp = with(density) { WindowInsets.ime.getBottom(this).toDp() } - var ctrlLatched by rememberSaveable { mutableStateOf(false) } - var altLatched by rememberSaveable { mutableStateOf(false) } - var pendingLatchedConsume by remember { mutableStateOf(false) } var pinchGestureLock by remember { mutableStateOf(false) } var terminalTouchStartX by remember { mutableFloatStateOf(0f) } var terminalTouchStartY by remember { mutableFloatStateOf(0f) } - val terminalTouchSlop = remember(context) { - ViewConfiguration.get(context).scaledTouchSlop - } + val terminalTouchSlop = remember(context) { ViewConfiguration.get(context).scaledTouchSlop } var customTypeface by remember { mutableStateOf(loadTerminalTypeface(context)) } - val sessionHolder = remember { arrayOfNulls(1) } + var terminalView by remember { mutableStateOf(null) } + val terminalSurfaceColorArgb = colorScheme.surface.toArgb() val terminalOnSurfaceColorArgb = colorScheme.onSurface.toArgb() val terminalCursorColorArgb = if (colorScheme.surface.luminance() < 0.5f) 0xffffffff.toInt() else 0xff000000.toInt() - LaunchedEffect(asBundleShared.terminalFontSizeSp) { - if (terminalFontSizeSp != asBundleShared.terminalFontSizeSp) { - terminalFontSizeSp = asBundleShared.terminalFontSizeSp - } - } - - fun extractTranscript(session: TerminalSession): String { - val screen = session.emulator.getScreen() - return screen.getSelectedText( - selX1 = 0, - selY1 = -screen.activeTranscriptRows, - selX2 = session.emulator.mColumns, - selY2 = session.emulator.mRows - 1, - joinBackLines = false, - joinFullLines = false, - ).trim('\n') - } - - fun syncOutput() { - val session = sessionHolder[0] ?: return - onOutputChange(extractTranscript(session)) - terminalView?.onScreenUpdated() - } - - fun writeBytesToShell( - data: ByteArray, - offset: Int, - count: Int, - ) { - val stream = shellStream - if (stream == null || !shellReady || stream.closed) { - return - } - val payload = data.copyOfRange(offset, offset + count) - taskScope.launch { - val result = runCatching { - shellWriteMutex.withLock { - stream.outputStream.write(payload) - stream.outputStream.flush() - } - } - withContext(Dispatchers.Main) { - result.onFailure { error -> - snackbar.show("终端输入失败: ${error.message ?: error.javaClass.simpleName}") - } - } - } - } - - fun writeClipboardToShell() { - val text = LocalInputService.getClipboardText(context) - if (!text.isNullOrBlank()) { - val bytes = text.toByteArray(StandardCharsets.UTF_8) - writeBytesToShell(bytes, 0, bytes.size) - } - } - val terminalSession = remember { TerminalSession( - shellWriter = ::writeBytesToShell, - onScreenUpdated = ::syncOutput, + shellWriter = { data, offset, count -> + viewModel.writeBytesToShell(data, offset, count) + }, + onScreenUpdated = { + viewModel.syncOutput { onOutputChange(it) } + terminalView?.onScreenUpdated() + }, onCopyTextToClipboardRequested = { text -> LocalInputService.setClipboardText(context, text) - uiScope.launch { - snackbar.show("已复制到剪贴板") - } + snackbar.show("已复制到剪贴板") }, - onPasteTextFromClipboardRequested = ::writeClipboardToShell, + onPasteTextFromClipboardRequested = { viewModel.writeClipboardToShell(context) }, onBellRequested = {}, - ) - } - sessionHolder[0] = terminalSession - - fun applyTerminalThemeColors() { - val colors = terminalSession.emulator.mColors.mCurrentColors - colors[TextStyle.COLOR_INDEX_BACKGROUND] = terminalSurfaceColorArgb - colors[TextStyle.COLOR_INDEX_FOREGROUND] = terminalOnSurfaceColorArgb - colors[TextStyle.COLOR_INDEX_CURSOR] = terminalCursorColorArgb + ).also { viewModel.sessionHolder[0] = it } } - fun updateTerminalFontSize(newValue: Float) { - val clamped = newValue.coerceIn(MIN_TERMINAL_FONT_SIZE_SP, MAX_TERMINAL_FONT_SIZE_SP) - if (clamped == terminalFontSizeSp) { - return + fun requestTerminalFocus() { + terminalView?.requestFocusFromTouch() + terminalView?.requestFocus() + terminalView?.post { + terminalView?.requestFocusFromTouch() + terminalView?.requestFocus() + terminalView?.let(LocalInputService::showSoftKeyboard) } - terminalFontSizeSp = clamped - terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() }) - uiScope.launch { - val latest = appSettings.bundleState.value - if (latest.terminalFontSizeSp != clamped) { - appSettings.updateBundle { - it.copy(terminalFontSizeSp = clamped) - } - } + } + + fun openShellSession(showKeyboardAfterConnect: Boolean) { + viewModel.openShellSession(showKeyboardAfterConnect, ::requestTerminalFocus) + } + + fun updateFontSize(newValue: Float) { + viewModel.updateTerminalFontSize(newValue) { clamped -> + terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() }) } } fun showFontSizeSnackbar() { - uiScope.launch { - snackbar.hostState.newestSnackbarData()?.dismiss() - val result = snackbar.hostState.showSnackbar( - message = "终端字号 ${terminalFontSizeSp.roundToInt()}sp", - actionLabel = "恢复默认", - withDismissAction = true, - duration = SnackbarDuration.Short, - ) - if (result == SnackbarResult.ActionPerformed) { - updateTerminalFontSize(DEFAULT_TERMINAL_FONT_SIZE_SP) - } - } + viewModel.launchFontSizeSnackbar( + fontSizeSp = terminalFontSizeSp, + hostState = snackbar.hostState, + onReset = { clamped -> + terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() }) + }, + ) } - fun handleTerminalTouchInterception( - view: TerminalView, - event: MotionEvent, - ): Boolean { + fun applyTheme() { + viewModel.applyTerminalThemeColors( + terminalSurfaceColorArgb, + terminalOnSurfaceColorArgb, + terminalCursorColorArgb, + ) + } + + fun handleTerminalTouchInterception(view: TerminalView, event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { terminalTouchStartX = event.x @@ -384,7 +300,6 @@ private fun TerminalPage( } } } - val shouldUnlock = event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL || (event.actionMasked == MotionEvent.ACTION_POINTER_UP && event.pointerCount <= 2) @@ -398,143 +313,18 @@ private fun TerminalPage( return false } - fun openShellSession(showKeyboardAfterConnect: Boolean) { - if (shellStream != null || shellConnecting) { - if (shellReady && showKeyboardAfterConnect) { - terminalView?.requestFocusFromTouch() - terminalView?.requestFocus() - terminalView?.post { - terminalView?.requestFocusFromTouch() - terminalView?.requestFocus() - terminalView?.let(LocalInputService::showSoftKeyboard) - } - } - return - } - shellConnecting = true - taskScope.launch { - val streamResult = runCatching { - NativeAdbService.openShellStream("") - } - val stream = streamResult.getOrElse { error -> - withContext(Dispatchers.Main) { - shellConnecting = false - shellReady = false - snackbar.show("终端会话创建失败: ${error.message ?: error.javaClass.simpleName}") - } - return@launch - } - - withContext(Dispatchers.Main) { - shellStream = stream - shellReady = true - shellConnecting = false - if (showKeyboardAfterConnect) { - terminalView?.requestFocusFromTouch() - terminalView?.requestFocus() - terminalView?.post { - terminalView?.requestFocusFromTouch() - terminalView?.requestFocus() - terminalView?.let(LocalInputService::showSoftKeyboard) - } - } - } - - val buffer = ByteArray(4096) - try { - while (!stream.closed) { - val count = stream.inputStream.read(buffer) - if (count <= 0) { - break - } - withContext(Dispatchers.Main) { - terminalSession.append(buffer, count) - } - } - } catch (error: Throwable) { - withContext(Dispatchers.Main) { - snackbar.show("终端输出失败: ${error.message ?: error.javaClass.simpleName}") - } - } finally { - runCatching { stream.close() } - withContext(Dispatchers.Main) { - if (shellStream === stream) { - shellStream = null - } - shellReady = false - shellConnecting = false - } - } - } - } - - fun consumeLatchedModifiers(): Int { - var modifiers = 0 - if (ctrlLatched) modifiers = modifiers or KeyHandler.KEYMOD_CTRL - if (altLatched) modifiers = modifiers or KeyHandler.KEYMOD_ALT - ctrlLatched = false - altLatched = false - pendingLatchedConsume = false - return modifiers - } - - fun consumeLatchedVisualState() { - ctrlLatched = false - altLatched = false - pendingLatchedConsume = false - } - - fun applyCtrlModifier(ch: Char): Char { - return when (ch) { - in 'a'..'z' -> (ch.code - 'a'.code + 1).toChar() - in 'A'..'Z' -> (ch.code - 'A'.code + 1).toChar() - ' ' -> 0.toChar() - '[' -> 27.toChar() - '\\' -> 28.toChar() - ']' -> 29.toChar() - '^' -> 30.toChar() - '_', '/' -> 31.toChar() - else -> ch - } - } - - fun writeLiteralKey(text: String) { - var payload = text - val modifiers = consumeLatchedModifiers() - if ((modifiers and KeyHandler.KEYMOD_CTRL) != 0) { - payload = payload.map(::applyCtrlModifier).joinToString(separator = "") - } - if ((modifiers and KeyHandler.KEYMOD_ALT) != 0) { - payload = "\u001B$payload" - } - val bytes = payload.toByteArray(StandardCharsets.UTF_8) - writeBytesToShell(bytes, 0, bytes.size) - } - - fun writeSpecialKey(keyCode: Int) { - val modifiers = consumeLatchedModifiers() - val sequence = KeyHandler.getCode( - keyCode, - modifiers, - terminalSession.emulator.isCursorKeysApplicationMode(), - terminalSession.emulator.isKeypadApplicationMode(), - ) ?: return - val bytes = sequence.toByteArray(StandardCharsets.UTF_8) - writeBytesToShell(bytes, 0, bytes.size) - } - val terminalViewClient = remember { object : TerminalViewClient { override fun onScale(scale: Float): Float { when { scale >= FONT_SCALE_STEP_THRESHOLD -> { - updateTerminalFontSize(terminalFontSizeSp + 1f) + updateFontSize(terminalFontSizeSp + 1f) showFontSizeSnackbar() return 1f } scale <= 1f / FONT_SCALE_STEP_THRESHOLD -> { - updateTerminalFontSize(terminalFontSizeSp - 1f) + updateFontSize(terminalFontSizeSp - 1f) showFontSizeSnackbar() return 1f } @@ -543,55 +333,42 @@ private fun TerminalPage( } override fun onSingleTapUp(e: MotionEvent) { - openShellSession(showKeyboardAfterConnect = true) + openShellSession(true) } - override fun shouldBackButtonBeMappedToEscape(): Boolean = false - - override fun shouldEnforceCharBasedInput(): Boolean = false - - override fun shouldUseCtrlSpaceWorkaround(): Boolean = false - - override fun isTerminalViewSelected(): Boolean = true - + override fun shouldBackButtonBeMappedToEscape() = false + override fun shouldEnforceCharBasedInput() = false + override fun shouldUseCtrlSpaceWorkaround() = false + override fun isTerminalViewSelected() = true override fun copyModeChanged(copyMode: Boolean) = Unit - override fun onKeyDown( keyCode: Int, e: KeyEvent, session: TerminalSession, ): Boolean { - if (e.action == KeyEvent.ACTION_DOWN && (ctrlLatched || altLatched)) { - pendingLatchedConsume = true - } + if (e.action == KeyEvent.ACTION_DOWN && (viewModel.ctrlLatched || viewModel.altLatched)) + viewModel.pendingLatchedConsume = true return false } override fun onKeyUp(keyCode: Int, e: KeyEvent): Boolean { - if (e.action == KeyEvent.ACTION_UP && pendingLatchedConsume) { - consumeLatchedVisualState() - } + if (e.action == KeyEvent.ACTION_UP && viewModel.pendingLatchedConsume) + viewModel.consumeLatchedVisualState() return false } - override fun onLongPress(event: MotionEvent): Boolean = false - - override fun readControlKey(): Boolean = ctrlLatched - - override fun readAltKey(): Boolean = altLatched - - override fun readShiftKey(): Boolean = false - - override fun readFnKey(): Boolean = false - + override fun onLongPress(event: MotionEvent) = false + override fun readControlKey() = viewModel.ctrlLatched + override fun readAltKey() = viewModel.altLatched + override fun readShiftKey() = false + override fun readFnKey() = false override fun onCodePoint( codePoint: Int, ctrlDown: Boolean, session: TerminalSession, ): Boolean { - if (ctrlLatched || altLatched || pendingLatchedConsume) { - consumeLatchedVisualState() - } + if (viewModel.ctrlLatched || viewModel.altLatched || viewModel.pendingLatchedConsume) + viewModel.consumeLatchedVisualState() return false } @@ -601,7 +378,7 @@ private fun TerminalPage( start = true, startOnlyIfCursorEnabled = true ) - syncOutput() + viewModel.syncOutput { onOutputChange(it) } } override fun logError(tag: String?, message: String?) { @@ -624,11 +401,7 @@ private fun TerminalPage( Log.v(tag ?: LOG_TAG, message.orEmpty()) } - override fun logStackTraceWithMessage( - tag: String?, - message: String?, - e: Exception?, - ) { + override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) { Log.e(tag ?: LOG_TAG, message, e) } @@ -638,50 +411,48 @@ private fun TerminalPage( } } + // reset on clear output LaunchedEffect(output) { - if (output.isEmpty() && extractTranscript(terminalSession).isNotEmpty()) { + if (output.isEmpty() && viewModel.extractTranscript(terminalSession).isNotEmpty()) { terminalSession.reset() - applyTerminalThemeColors() + applyTheme() terminalView?.mEmulator = terminalSession.emulator terminalView?.onScreenUpdated() - syncOutput() + viewModel.syncOutput { onOutputChange(it) } } } + // auto-connect when tab becomes active LaunchedEffect(isActive) { if (!isActive) { onTerminalGestureLockChanged(false) return@LaunchedEffect } customTypeface = loadTerminalTypeface(context) - if (shellStream == null && !shellConnecting) { - val connected = runCatching { NativeAdbService.isConnected() }.getOrDefault(false) - if (connected) { - openShellSession(showKeyboardAfterConnect = false) - } - } + viewModel.autoConnectIfNeeded(::requestTerminalFocus) } - LaunchedEffect(colorScheme.surface, colorScheme.onSurface) { - applyTerminalThemeColors() + // theme changes + LaunchedEffect( + colorScheme.surface, + colorScheme.onSurface + ) { + applyTheme() terminalView?.onScreenUpdated() - syncOutput() + viewModel.syncOutput { onOutputChange(it) } + } + + // font size sync from storage + LaunchedEffect(asBundle.terminalFontSizeSp) { + if (terminalFontSizeSp != asBundle.terminalFontSizeSp) { + updateFontSize(asBundle.terminalFontSizeSp) + } } DisposableEffect(Unit) { onDispose { - val latestFontSize = terminalFontSizeSp onTerminalGestureLockChanged(false) - taskScope.launch { - val latest = appSettings.bundleState.value - if (latest.terminalFontSizeSp != latestFontSize) { - appSettings.updateBundle { - it.copy(terminalFontSizeSp = latestFontSize) - } - } - } - runCatching { shellStream?.close() } - taskScope.cancel() + viewModel.closeShell() } } @@ -708,15 +479,10 @@ private fun TerminalPage( setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() }) setTypeface(customTypeface ?: Typeface.MONOSPACE) attachSession(terminalSession) - applyTerminalThemeColors() + applyTheme() setTerminalCursorBlinkerRate(500) - setTerminalCursorBlinkerState( - start = true, - startOnlyIfCursorEnabled = true, - ) - setOnTouchListener { _, event -> - handleTerminalTouchInterception(this, event) - } + setTerminalCursorBlinkerState(start = true, startOnlyIfCursorEnabled = true) + setOnTouchListener { _, event -> handleTerminalTouchInterception(this, event) } terminalView = this } }, @@ -724,126 +490,118 @@ private fun TerminalPage( terminalView = it it.setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() }) it.setTypeface(customTypeface ?: Typeface.MONOSPACE) - if (it.currentSession !== terminalSession) { - it.attachSession(terminalSession) - } - it.setOnTouchListener { _, event -> - handleTerminalTouchInterception(it, event) - } - applyTerminalThemeColors() + if (it.currentSession !== terminalSession) it.attachSession(terminalSession) + it.setOnTouchListener { _, event -> handleTerminalTouchInterception(it, event) } + applyTheme() }, modifier = Modifier .fillMaxWidth() .weight(1f), ) - Row( - modifier = Modifier.fillMaxWidth(), - ) { + Row(modifier = Modifier.fillMaxWidth()) { TerminalExtraKeyButton( - label = "ESC", - modifier = Modifier.weight(1f), + "ESC", + Modifier.weight(1f), ) { haptic.contextClick() - writeLiteralKey("\u001B") + viewModel.writeLiteralKey("") } TerminalExtraKeyButton( - label = "/", - modifier = Modifier.weight(1f), + "/", + Modifier.weight(1f), ) { haptic.contextClick() - writeLiteralKey("/") + viewModel.writeLiteralKey("/") } TerminalExtraKeyButton( - label = "-", - modifier = Modifier.weight(1f), + "-", + Modifier.weight(1f), ) { haptic.contextClick() - writeLiteralKey("-") + viewModel.writeLiteralKey("-") } TerminalExtraKeyButton( - label = "HOME", - modifier = Modifier.weight(1f), + "HOME", + Modifier.weight(1f), ) { haptic.contextClick() - writeSpecialKey(KeyEvent.KEYCODE_MOVE_HOME) + viewModel.writeSpecialKey(KeyEvent.KEYCODE_MOVE_HOME) } TerminalExtraKeyButton( - label = "↑", - modifier = Modifier.weight(1f), + "↑", + Modifier.weight(1f), ) { haptic.contextClick() - writeSpecialKey(KeyEvent.KEYCODE_DPAD_UP) + viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_UP) } TerminalExtraKeyButton( - label = "END", - modifier = Modifier.weight(1f), + "END", + Modifier.weight(1f), ) { haptic.contextClick() - writeSpecialKey(KeyEvent.KEYCODE_MOVE_END) + viewModel.writeSpecialKey(KeyEvent.KEYCODE_MOVE_END) } TerminalExtraKeyButton( - label = "PGUP", - modifier = Modifier.weight(1f), + "PGUP", + Modifier.weight(1f), ) { haptic.contextClick() - writeSpecialKey(KeyEvent.KEYCODE_PAGE_UP) + viewModel.writeSpecialKey(KeyEvent.KEYCODE_PAGE_UP) } } - Row( - modifier = Modifier.fillMaxWidth(), - ) { + Row(modifier = Modifier.fillMaxWidth()) { TerminalExtraKeyButton( - label = "TAB", - modifier = Modifier.weight(1f), + "TAB", + Modifier.weight(1f), ) { haptic.contextClick() - writeSpecialKey(KeyEvent.KEYCODE_TAB) + viewModel.writeSpecialKey(KeyEvent.KEYCODE_TAB) } TerminalExtraKeyButton( - label = "CTRL", - active = ctrlLatched, - modifier = Modifier.weight(1f), + "CTRL", + Modifier.weight(1f), + active = viewModel.ctrlLatched ) { haptic.contextClick() - ctrlLatched = !ctrlLatched + viewModel.ctrlLatched = !viewModel.ctrlLatched } TerminalExtraKeyButton( - label = "ALT", - active = altLatched, - modifier = Modifier.weight(1f), + "ALT", + Modifier.weight(1f), + active = viewModel.altLatched ) { haptic.contextClick() - altLatched = !altLatched + viewModel.altLatched = !viewModel.altLatched } TerminalExtraKeyButton( - label = "←", - modifier = Modifier.weight(1f), + "←", + Modifier.weight(1f), ) { haptic.contextClick() - writeSpecialKey(KeyEvent.KEYCODE_DPAD_LEFT) + viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_LEFT) } TerminalExtraKeyButton( - label = "↓", - modifier = Modifier.weight(1f), + "↓", + Modifier.weight(1f), ) { haptic.contextClick() - writeSpecialKey(KeyEvent.KEYCODE_DPAD_DOWN) + viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_DOWN) } TerminalExtraKeyButton( - label = "→", - modifier = Modifier.weight(1f), + "→", + Modifier.weight(1f), ) { haptic.contextClick() - writeSpecialKey(KeyEvent.KEYCODE_DPAD_RIGHT) + viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_RIGHT) } TerminalExtraKeyButton( - label = "PGDN", - modifier = Modifier.weight(1f), + "PGDN", + Modifier.weight(1f), ) { haptic.contextClick() - writeSpecialKey(KeyEvent.KEYCODE_PAGE_DOWN) + viewModel.writeSpecialKey(KeyEvent.KEYCODE_PAGE_DOWN) } } } @@ -859,7 +617,6 @@ private fun TerminalExtraKeyButton( val content = if (active) colorScheme.primary else colorScheme.onSurface - Box( modifier = modifier .height(32.dp) @@ -898,7 +655,7 @@ private fun TerminalOutputBottomSheet( LazyColumn( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(2f / 3f), + .fillMaxHeight(2f / 3f) ) { item { TextField( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalViewModel.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalViewModel.kt new file mode 100644 index 0000000..80fe9e6 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/TerminalViewModel.kt @@ -0,0 +1,277 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.termux.terminal.KeyHandler +import com.termux.terminal.TerminalSession +import com.termux.terminal.TextStyle +import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream +import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService +import io.github.miuzarte.scrcpyforandroid.services.LocalInputService +import io.github.miuzarte.scrcpyforandroid.storage.BundleSyncDelegate +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import top.yukonga.miuix.kmp.basic.SnackbarHostState +import top.yukonga.miuix.kmp.basic.SnackbarResult +import java.nio.charset.StandardCharsets +import kotlin.math.roundToInt + +private const val DEFAULT_TERMINAL_FONT_SIZE_SP = 14f +private const val LOG_TAG = "TerminalScreen" + +internal class TerminalViewModel : ViewModel() { + + private val asBundleSync = BundleSyncDelegate( + sharedFlow = appSettings.bundleState, + save = { appSettings.saveBundle(it) }, + scope = viewModelScope, + ) + val asBundle = asBundleSync.value + + private val _terminalFontSizeSp = MutableStateFlow(asBundle.value.terminalFontSizeSp) + val terminalFontSizeSp: StateFlow = _terminalFontSizeSp.asStateFlow() + + fun updateTerminalFontSize(newValue: Float, onApplied: (Float) -> Unit) { + val clamped = newValue.coerceIn(1f, 32f) + if (clamped == _terminalFontSizeSp.value) return + _terminalFontSizeSp.value = clamped + asBundleSync.update { it.copy(terminalFontSizeSp = clamped) } + onApplied(clamped) + } + + fun resetFontSizeToDefault(onApplied: (Float) -> Unit) { + updateTerminalFontSize(DEFAULT_TERMINAL_FONT_SIZE_SP, onApplied) + } + + private val _shellReady = MutableStateFlow(false) + val shellReady: StateFlow = _shellReady.asStateFlow() + + private val _shellConnecting = MutableStateFlow(false) + val shellConnecting: StateFlow = _shellConnecting.asStateFlow() + + private val shellWriteMutex = Mutex() + private var shellStream: AdbSocketStream? = null + + val sessionHolder = arrayOfNulls(1) + + fun writeBytesToShell(data: ByteArray, offset: Int, count: Int) { + val stream = shellStream + if (stream == null || !_shellReady.value || stream.closed) return + val payload = data.copyOfRange(offset, offset + count) + viewModelScope.launch(Dispatchers.IO) { + val result = runCatching { + shellWriteMutex.withLock { + stream.outputStream.write(payload) + stream.outputStream.flush() + } + } + withContext(Dispatchers.Main) { + result.onFailure { error -> + snackbar("终端输入失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + } + + fun writeClipboardToShell(context: Context) { + val text = LocalInputService.getClipboardText(context) + if (!text.isNullOrBlank()) { + val bytes = text.toByteArray(StandardCharsets.UTF_8) + writeBytesToShell(bytes, 0, bytes.size) + } + } + + var ctrlLatched by mutableStateOf(false) + var altLatched by mutableStateOf(false) + var pendingLatchedConsume by mutableStateOf(false) + + fun consumeLatchedVisualState() { + ctrlLatched = false + altLatched = false + pendingLatchedConsume = false + } + + fun writeLiteralKey(text: String) { + var payload = text + if (ctrlLatched) { + payload = payload.map { applyCtrlModifier(it) }.joinToString(separator = "") + } + if (altLatched) { + payload = "$payload" + } + ctrlLatched = false + altLatched = false + pendingLatchedConsume = false + val bytes = payload.toByteArray(StandardCharsets.UTF_8) + writeBytesToShell(bytes, 0, bytes.size) + } + + fun writeSpecialKey(keyCode: Int) { + val session = sessionHolder[0] ?: return + var modifiers = 0 + if (ctrlLatched) modifiers = modifiers or KeyHandler.KEYMOD_CTRL + if (altLatched) modifiers = modifiers or KeyHandler.KEYMOD_ALT + ctrlLatched = false + altLatched = false + pendingLatchedConsume = false + val sequence = KeyHandler.getCode( + keyCode, modifiers, + session.emulator.isCursorKeysApplicationMode(), + session.emulator.isKeypadApplicationMode(), + ) ?: return + val bytes = sequence.toByteArray(StandardCharsets.UTF_8) + writeBytesToShell(bytes, 0, bytes.size) + } + + fun extractTranscript(session: TerminalSession): String { + val screen = session.emulator.getScreen() + return screen.getSelectedText( + selX1 = 0, selY1 = -screen.activeTranscriptRows, + selX2 = session.emulator.mColumns, selY2 = session.emulator.mRows - 1, + joinBackLines = false, joinFullLines = false, + ).trim('\n') + } + + fun syncOutput(onOutputChange: (String) -> Unit) { + val session = sessionHolder[0] ?: return + onOutputChange(extractTranscript(session)) + } + + fun applyTerminalThemeColors(surfaceArgb: Int, onSurfaceArgb: Int, cursorArgb: Int) { + val session = sessionHolder[0] ?: return + val colors = session.emulator.mColors.mCurrentColors + colors[TextStyle.COLOR_INDEX_BACKGROUND] = surfaceArgb + colors[TextStyle.COLOR_INDEX_FOREGROUND] = onSurfaceArgb + colors[TextStyle.COLOR_INDEX_CURSOR] = cursorArgb + } + + fun openShellSession(showKeyboardAfterConnect: Boolean, requestFocus: () -> Unit) { + if (shellStream != null || _shellConnecting.value) { + if (_shellReady.value && showKeyboardAfterConnect) requestFocus() + return + } + _shellConnecting.value = true + viewModelScope.launch(Dispatchers.IO) { + val streamResult = runCatching { + NativeAdbService.openShellStream("") + } + val stream = streamResult.getOrElse { error -> + withContext(Dispatchers.Main) { + _shellConnecting.value = false + _shellReady.value = false + snackbar("终端会话创建失败: ${error.message ?: error.javaClass.simpleName}") + } + return@launch + } + + withContext(Dispatchers.Main) { + shellStream = stream + _shellReady.value = true + _shellConnecting.value = false + if (showKeyboardAfterConnect) requestFocus() + } + + val buffer = ByteArray(4096) + try { + while (!stream.closed) { + val count = stream.inputStream.read(buffer) + if (count <= 0) break + withContext(Dispatchers.Main) { + sessionHolder[0]?.append(buffer, count) + } + } + } catch (error: Throwable) { + withContext(Dispatchers.Main) { + snackbar("终端输出失败: ${error.message ?: error.javaClass.simpleName}") + } + } finally { + runCatching { stream.close() } + withContext(Dispatchers.Main) { + if (shellStream === stream) shellStream = null + _shellReady.value = false + _shellConnecting.value = false + } + } + } + } + + fun autoConnectIfNeeded(onFocus: () -> Unit) { + if (_shellReady.value || _shellConnecting.value) return + viewModelScope.launch { + val connected = runCatching { + withContext(Dispatchers.IO) { NativeAdbService.isConnected() } + }.getOrDefault(false) + if (connected) openShellSession(false, onFocus) + } + } + + fun closeShell() { + runCatching { shellStream?.close() } + shellStream = null + _shellReady.value = false + _shellConnecting.value = false + } + + private val _snackbarEvents = Channel(Channel.BUFFERED) + val snackbarEvents: Flow = _snackbarEvents.receiveAsFlow() + + private fun snackbar(message: String) { + _snackbarEvents.trySend(message) + } + + fun launchFontSizeSnackbar( + fontSizeSp: Float, + hostState: SnackbarHostState, + onReset: (Float) -> Unit, + ) { + viewModelScope.launch { + hostState.newestSnackbarData()?.dismiss() + val result = hostState.showSnackbar( + message = "终端字号 ${fontSizeSp.roundToInt()}sp", + actionLabel = "恢复默认", + withDismissAction = true, + ) + if (result == SnackbarResult.ActionPerformed) { + resetFontSizeToDefault(onReset) + } + } + } + + init { + asBundleSync.start() + } + + override fun onCleared() { + closeShell() + runBlocking(Dispatchers.IO) { asBundleSync.flush() } + } + + companion object { + private fun applyCtrlModifier(ch: Char): Char = when (ch) { + in 'a'..'z' -> (ch.code - 'a'.code + 1).toChar() + in 'A'..'Z' -> (ch.code - 'A'.code + 1).toChar() + ' ' -> 0.toChar() + '[' -> 27.toChar() + '\\' -> 28.toChar() + ']' -> 29.toChar() + '^' -> 30.toChar() + '_', '/' -> 31.toChar() + else -> ch + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/OverlaySpinnerWithFallback.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/OverlaySpinnerWithFallback.kt new file mode 100644 index 0000000..ec2f650 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/OverlaySpinnerWithFallback.kt @@ -0,0 +1,97 @@ +package io.github.miuzarte.scrcpyforandroid.scaffolds + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import io.github.miuzarte.scrcpyforandroid.miuix.OverlaySpinnerPreference +import io.github.miuzarte.scrcpyforandroid.miuix.SpinnerEntry +import top.yukonga.miuix.kmp.basic.BasicComponentColors +import top.yukonga.miuix.kmp.basic.BasicComponentDefaults +import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator +import top.yukonga.miuix.kmp.basic.SpinnerColors +import top.yukonga.miuix.kmp.basic.SpinnerDefaults + +/** + * [OverlaySpinnerPreference] wrapper with fallback value display and loading state. + * + * When [dataLoaded] is false and [overrideEndActionValue] is set, the saved value is + * appended as a synthetic item so it remains visible before the list is fetched. + * When [dataLoading] is true, a "加载中..." entry with an [InfiniteProgressIndicator] + * icon is appended at the end. + */ +@Composable +fun OverlaySpinnerWithFallback( + items: List, + selectedIndex: Int, + title: String, + modifier: Modifier = Modifier, + titleColor: BasicComponentColors = BasicComponentDefaults.titleColor(), + summary: String? = null, + summaryColor: BasicComponentColors = BasicComponentDefaults.summaryColor(), + spinnerColors: SpinnerColors = SpinnerDefaults.spinnerColors(), + startAction: @Composable (() -> Unit)? = null, + bottomAction: (@Composable () -> Unit)? = null, + insideMargin: PaddingValues = BasicComponentDefaults.InsideMargin, + maxHeight: Dp? = null, + enabled: Boolean = true, + showValue: Boolean = true, + renderInRootScaffold: Boolean = true, + onExpandedChange: ((Boolean) -> Unit)? = null, + onSelectedIndexChange: ((Int) -> Unit)? = null, + dataLoaded: Boolean = true, + dataLoading: Boolean = false, + overrideEndActionValue: String? = null, +) { + val fallbackActive = !dataLoaded && !overrideEndActionValue.isNullOrBlank() + val fallbackIdx = items.size + val effectiveItems = remember(items, dataLoading, fallbackActive, overrideEndActionValue) { + buildList { + addAll(items) + if (fallbackActive) { + add( + SpinnerEntry( + title = overrideEndActionValue, + enabled = true, // 保持启用以显示蓝色而非灰色 + ) + ) + } + if (dataLoading) { + add( + SpinnerEntry( + icon = { mod -> InfiniteProgressIndicator(mod) }, + title = "加载中...", + enabled = false, + ) + ) + } + } + } + + val effectiveIndex = if (fallbackActive) fallbackIdx else selectedIndex + + OverlaySpinnerPreference( + items = effectiveItems, + selectedIndex = effectiveIndex, + title = title, + modifier = modifier, + titleColor = titleColor, + summary = summary, + summaryColor = summaryColor, + spinnerColors = spinnerColors, + startAction = startAction, + bottomAction = bottomAction, + insideMargin = insideMargin, + maxHeight = maxHeight, + enabled = enabled, + showValue = showValue, + renderInRootScaffold = renderInRootScaffold, + onExpandedChange = onExpandedChange, + onSelectedIndexChange = { idx -> + if (dataLoading) return@OverlaySpinnerPreference + if (fallbackActive && idx >= fallbackIdx) return@OverlaySpinnerPreference + onSelectedIndexChange?.invoke(idx) + }, + ) +} 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 8044c09..8719cf4 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 @@ -18,7 +18,6 @@ data class ClientOptions( // --crop=width:height:x:y var crop: String = "", // to server - // TODO: implement // --record var recordFilename: String = "", diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/BundleSync.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/BundleSync.kt new file mode 100644 index 0000000..65cfe88 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/BundleSync.kt @@ -0,0 +1,120 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Manages a local copy of a bundle from persistent storage with debounced save. + * + * Usage in a ViewModel: + * ``` + * val asBundle = BundleSyncDelegate( + * sharedFlow = appSettings.bundleState, + * save = { appSettings.saveBundle(it) }, + * scope = viewModelScope, + * ) + * + * init { asBundle.start() } + * + * override fun onCleared() { + * runBlocking(Dispatchers.IO) { asBundle.flush() } + * } + * ``` + */ +@OptIn(FlowPreview::class) +class BundleSyncDelegate( + private val sharedFlow: StateFlow, + private val save: suspend (T) -> Unit, + private val scope: CoroutineScope, + private val delayMs: Long = 600L, +) { + private val _value = MutableStateFlow(sharedFlow.value) + val value: StateFlow = _value.asStateFlow() + + fun update(transform: (T) -> T) { + _value.update(transform) + } + + fun start() { + scope.launch { + sharedFlow.collectLatest { shared -> + if (_value.value != shared) _value.value = shared + } + } + scope.launch { + _value.debounce(delayMs).collectLatest { v -> + if (v != sharedFlow.value) save(v) + } + } + } + + suspend fun flush() { + val latest = _value.value + if (latest != sharedFlow.value) save(latest) + } +} + +/** + * Composable equivalent of [BundleSyncDelegate] for files not yet migrated to + * ViewModel. Returns a mutable state backed by [sharedFlow] with debounced save. + * + * Usage: + * ``` + * var asBundle by rememberBundleState( + * sharedFlow = appSettings.bundleState, + * save = { appSettings.saveBundle(it) }, + * ) + * ``` + */ +@Composable +fun rememberBundleState( + sharedFlow: StateFlow, + save: suspend (T) -> Unit, +): MutableState { + val shared by sharedFlow.collectAsState() + val sharedLatest by rememberUpdatedState(shared) + val state = rememberSaveable(shared) { mutableStateOf(shared) } + var local by state + val localLatest by rememberUpdatedState(local) + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + + LaunchedEffect(shared) { + if (local != shared) local = shared + } + LaunchedEffect(local) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (local != sharedLatest) save(local) + } + DisposableEffect(Unit) { + onDispose { + kotlinx.coroutines.runBlocking(Dispatchers.IO) { + if (localLatest != sharedFlow.value) save(localLatest) + } + taskScope.cancel() + } + } + + return state +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt deleted file mode 100644 index 22e0ec3..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.widgets - -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import top.yukonga.miuix.kmp.basic.BasicComponent -import top.yukonga.miuix.kmp.basic.BasicComponentColors -import top.yukonga.miuix.kmp.basic.BasicComponentDefaults -import top.yukonga.miuix.kmp.basic.DropdownArrowEndAction -import top.yukonga.miuix.kmp.basic.DropdownColors -import top.yukonga.miuix.kmp.basic.DropdownDefaults -import top.yukonga.miuix.kmp.basic.DropdownImpl -import top.yukonga.miuix.kmp.basic.HorizontalDivider -import top.yukonga.miuix.kmp.basic.ListPopupColumn -import top.yukonga.miuix.kmp.basic.PopupPositionProvider -import top.yukonga.miuix.kmp.basic.Text -import top.yukonga.miuix.kmp.overlay.OverlayListPopup -import top.yukonga.miuix.kmp.theme.MiuixTheme - -data class MultiGroupsDropdownGroup( - val options: List, - val selectedIndex: Int, - val onSelectedIndexChange: (Int) -> Unit, -) - -@Composable -fun MultiGroupsDropdownPreference( - title: String, - modifier: Modifier = Modifier, - summary: String? = null, - groups: List, - titleColor: BasicComponentColors = BasicComponentDefaults.titleColor(), - summaryColor: BasicComponentColors = BasicComponentDefaults.summaryColor(), - insideMargin: PaddingValues = BasicComponentDefaults.InsideMargin, - dropdownColors: DropdownColors = DropdownDefaults.dropdownColors(), - enabled: Boolean = true, - showValue: Boolean = true, -) { - val interactionSource = remember { MutableInteractionSource() } - val isExpanded = remember { mutableStateOf(false) } - val isHoldDown = remember { mutableStateOf(false) } - val haptic = LocalHapticFeedback.current - val hapticLatest by rememberUpdatedState(haptic) - - val hasOptions = groups.any { it.options.isNotEmpty() } - val actualEnabled = enabled && hasOptions - val actionColor = if (actualEnabled) { - MiuixTheme.colorScheme.onSurfaceVariantActions - } else { - MiuixTheme.colorScheme.disabledOnSecondaryVariant - } - val selectedValueText = groups.joinToString("\n") { group -> - group.options.getOrNull(group.selectedIndex).orEmpty() - }.ifBlank { null } - - BasicComponent( - modifier = modifier, - interactionSource = interactionSource, - insideMargin = insideMargin, - title = title, - titleColor = titleColor, - summary = summary, - summaryColor = summaryColor, - endActions = { - if (showValue && !selectedValueText.isNullOrBlank()) { - Text( - text = selectedValueText, - modifier = Modifier.padding(end = 8.dp), - fontSize = MiuixTheme.textStyles.body2.fontSize, - color = actionColor, - textAlign = TextAlign.End, - lineHeight = MiuixTheme.textStyles.body2.lineHeight, - ) - } - DropdownArrowEndAction(actionColor = actionColor) - if (hasOptions) { - OverlayListPopup( - show = isExpanded.value, - alignment = PopupPositionProvider.Align.End, - onDismissRequest = { isExpanded.value = false }, - onDismissFinished = { isHoldDown.value = false }, - ) { - ListPopupColumn { - MultiGroupsDropdown( - groups = groups, - dropdownColors = dropdownColors, - ) - } - } - } - }, - onClick = { - if (actualEnabled) { - isExpanded.value = !isExpanded.value - if (isExpanded.value) { - isHoldDown.value = true - hapticLatest.performHapticFeedback(HapticFeedbackType.ContextClick) - } - } - }, - holdDownState = isHoldDown.value, - enabled = actualEnabled, - ) -} - -@Composable -fun MultiGroupsDropdown( - groups: List, - modifier: Modifier = Modifier, - dropdownColors: DropdownColors = DropdownDefaults.dropdownColors(), -) { - groups.forEachIndexed { groupIndex, group -> - group.options.forEachIndexed { optionIndex, option -> - DropdownImpl( - text = option, - optionSize = group.options.size, - isSelected = optionIndex == group.selectedIndex, - dropdownColors = dropdownColors, - index = optionIndex, - onSelectedIndexChange = group.onSelectedIndexChange, - ) - } - if (groupIndex != groups.lastIndex) { - HorizontalDivider(modifier = modifier.padding(horizontal = 20.dp)) - } - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/PopupMenuItem.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/PopupMenuItem.kt deleted file mode 100644 index 14d4d05..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/PopupMenuItem.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.widgets - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import top.yukonga.miuix.kmp.basic.DropdownImpl -import top.yukonga.miuix.kmp.basic.Text -import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme -import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles - -@Composable -fun PopupMenuItem( - text: String, - optionSize: Int, - index: Int, - enabled: Boolean = true, - onSelectedIndexChange: (Int) -> Unit, -) { - if (enabled) { - DropdownImpl( - text = text, - optionSize = optionSize, - isSelected = false, - index = index, - onSelectedIndexChange = onSelectedIndexChange, - ) - return - } - - val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem - val additionalBottomPadding = - if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem - Text( - text = text, - fontSize = textStyles.body1.fontSize, - fontWeight = FontWeight.Medium, - color = colorScheme.disabledOnSecondaryVariant, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.PopupHorizontal) - .padding(top = additionalTopPadding, bottom = additionalBottomPadding), - ) -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cd7e936..b7dcb06 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ androidxSecurityCrypto = "1.1.0" androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } diff --git a/settings.gradle.kts b/settings.gradle.kts index c3189b0..5e58ae9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,5 @@ dependencyResolutionManagement { } rootProject.name = "Scrcpy" +includeBuild("submodule/miuix") include(":app") diff --git a/submodule/miuix b/submodule/miuix new file mode 160000 index 0000000..81ad71b --- /dev/null +++ b/submodule/miuix @@ -0,0 +1 @@ +Subproject commit 81ad71b1c6e56d91313956df384bfd40bbe0f9ce