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
This commit is contained in:
謬紗特
2026-04-28 21:01:00 +08:00
committed by GitHub
parent f97976847e
commit a3ba7fc918
28 changed files with 3015 additions and 2716 deletions

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ google-services.json
# Android Profiling # Android Profiling
*.hprof *.hprof
.claude/
.kiro/ .kiro/
.kotlin/ .kotlin/

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "submodule/miuix"]
path = submodule/miuix
url = https://github.com/compose-miuix-ui/miuix.git

View File

@@ -1,6 +1,12 @@
# Change Log # Change Log
~~什么我居然开始写更新日志了~~ ## 0.3.0
该版本主要进行代码重构,引入 ViewModel 以优化代码结构
- 重构: 部分自行实现的组件迁移至 miuix
- 引入子模块以在本地编译 miuix 的最新提交
- 新增: 所有参数页中的五个列表展开即触发懒加载
## 0.2.9 ## 0.2.9

View File

@@ -128,9 +128,17 @@
- Android NDK `29.0.14206865` - Android NDK `29.0.14206865`
```bash ```bash
git clone --recursive https://github.com/Miuzarte/ScrcpyForAndroid.git
cd ScrcpyForAndroid
./gradlew assembleDebug ./gradlew assembleDebug
``` ```
已克隆但没有拉取子模块:
```bash
git submodule update --init --recursive
```
specific abi: specific abi:
```bash ```bash

View File

@@ -126,6 +126,7 @@ androidComponents {
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.pip) implementation(libs.androidx.core.pip)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))

View File

@@ -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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues 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.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.SpinnerColors import top.yukonga.miuix.kmp.basic.SpinnerColors
import top.yukonga.miuix.kmp.basic.SpinnerDefaults 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.basic.Text
import top.yukonga.miuix.kmp.overlay.OverlayListPopup import top.yukonga.miuix.kmp.overlay.OverlayListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme 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 @Composable
fun SuperSpinner( fun OverlaySpinnerPreference(
items: List<SpinnerEntry>, items: List<SpinnerEntry>,
selectedIndex: Int, selectedIndex: Int,
title: String, title: String,
@@ -49,21 +52,26 @@ fun SuperSpinner(
enabled: Boolean = true, enabled: Boolean = true,
showValue: Boolean = true, showValue: Boolean = true,
renderInRootScaffold: Boolean = true, renderInRootScaffold: Boolean = true,
onExpandedChange: ((Boolean) -> Unit)? = null,
onSelectedIndexChange: ((Int) -> Unit)? = null, onSelectedIndexChange: ((Int) -> Unit)? = null,
overrideEndActionValue: String? = null,
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isDropdownExpanded = rememberSaveable { mutableStateOf(false) } val isDropdownExpanded = rememberSaveable { mutableStateOf(false) }
val isHoldDown = remember { mutableStateOf(false) } val isHoldDown = remember { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val hapticLatest by rememberUpdatedState(haptic) 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 itemsNotEmpty = items.isNotEmpty()
val actualEnabled = enabled && itemsNotEmpty 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) { val actionColor = if (actualEnabled) {
MiuixTheme.colorScheme.onSurfaceVariantActions MiuixTheme.colorScheme.onSurfaceVariantActions
@@ -74,10 +82,10 @@ fun SuperSpinner(
val handleClick = remember(actualEnabled) { val handleClick = remember(actualEnabled) {
{ {
if (actualEnabled) { if (actualEnabled) {
isDropdownExpanded.value = !isDropdownExpanded.value setExpanded(!isDropdownExpanded.value)
if (isDropdownExpanded.value) { if (isDropdownExpanded.value) {
isHoldDown.value = true isHoldDown.value = true
hapticLatest.performHapticFeedback(HapticFeedbackType.ContextClick) currentHapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
} }
} }
} }
@@ -93,9 +101,9 @@ fun SuperSpinner(
summaryColor = summaryColor, summaryColor = summaryColor,
startAction = startAction, startAction = startAction,
endActions = { endActions = {
if (endActionText != null) { if (showValue && itemsNotEmpty) {
Text( Text(
text = endActionText, text = items[selectedIndex].title ?: "",
modifier = Modifier modifier = Modifier
.padding(end = 8.dp) .padding(end = 8.dp)
.align(Alignment.CenterVertically) .align(Alignment.CenterVertically)
@@ -105,22 +113,19 @@ fun SuperSpinner(
textAlign = TextAlign.End, textAlign = TextAlign.End,
) )
} }
DropdownArrowEndAction( DropdownArrowEndAction(actionColor = actionColor)
actionColor = actionColor,
)
if (itemsNotEmpty) { if (itemsNotEmpty) {
SuperSpinnerPopup( OverlaySpinnerPopup(
items = items, items = items,
selectedIndex = selectedIndex, selectedIndex = selectedIndex,
isDropdownExpanded = isDropdownExpanded.value, isDropdownExpanded = isDropdownExpanded.value,
onDismiss = { isDropdownExpanded.value = false }, onDismiss = { setExpanded(false) },
onDismissFinished = { isHoldDown.value = false }, onDismissFinished = { isHoldDown.value = false },
maxHeight = maxHeight, maxHeight = maxHeight,
hapticFeedback = haptic, hapticFeedback = hapticFeedback,
spinnerColors = spinnerColors, spinnerColors = spinnerColors,
renderInRootScaffold = renderInRootScaffold, renderInRootScaffold = renderInRootScaffold,
onSelectedIndexChange = onSelectedIndexChange, onSelectedIndexChange = onSelectedIndexChange,
forceNoSelectedState = forceOverrideValue,
) )
} }
}, },
@@ -132,7 +137,7 @@ fun SuperSpinner(
} }
@Composable @Composable
private fun SuperSpinnerPopup( private fun OverlaySpinnerPopup(
items: List<SpinnerEntry>, items: List<SpinnerEntry>,
selectedIndex: Int, selectedIndex: Int,
isDropdownExpanded: Boolean, isDropdownExpanded: Boolean,
@@ -143,14 +148,13 @@ private fun SuperSpinnerPopup(
spinnerColors: SpinnerColors, spinnerColors: SpinnerColors,
renderInRootScaffold: Boolean, renderInRootScaffold: Boolean,
onSelectedIndexChange: ((Int) -> Unit)?, onSelectedIndexChange: ((Int) -> Unit)?,
forceNoSelectedState: Boolean,
) { ) {
val haptic = LocalHapticFeedback.current
val onSelectState = rememberUpdatedState(onSelectedIndexChange) val onSelectState = rememberUpdatedState(onSelectedIndexChange)
val currentOnDismiss by rememberUpdatedState(onDismiss) val currentOnDismiss by rememberUpdatedState(onDismiss)
val currentHapticFeedback by rememberUpdatedState(hapticFeedback)
val onItemSelected: (Int) -> Unit = remember { val onItemSelected: (Int) -> Unit = remember {
{ selectedIdx -> { selectedIdx ->
haptic.performHapticFeedback(HapticFeedbackType.Confirm) currentHapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
onSelectState.value?.invoke(selectedIdx) onSelectState.value?.invoke(selectedIdx)
currentOnDismiss() currentOnDismiss()
} }
@@ -169,10 +173,11 @@ private fun SuperSpinnerPopup(
SpinnerItemImpl( SpinnerItemImpl(
entry = spinnerEntry, entry = spinnerEntry,
entryCount = items.size, entryCount = items.size,
isSelected = !forceNoSelectedState && selectedIndex == index, isSelected = selectedIndex == index,
index = index, index = index,
spinnerColors = spinnerColors, spinnerColors = spinnerColors,
dialogMode = false, dialogMode = false,
enabled = spinnerEntry.enabled,
onSelectedIndexChange = onItemSelected, onSelectedIndexChange = onItemSelected,
) )
} }

View File

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

View File

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

View File

@@ -64,7 +64,6 @@ import top.yukonga.miuix.kmp.blur.BlendColorEntry
import top.yukonga.miuix.kmp.blur.BlurBlendMode import top.yukonga.miuix.kmp.blur.BlurBlendMode
import top.yukonga.miuix.kmp.blur.BlurColors import top.yukonga.miuix.kmp.blur.BlurColors
import top.yukonga.miuix.kmp.blur.BlurDefaults 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.isRenderEffectSupported
import top.yukonga.miuix.kmp.blur.layerBackdrop import top.yukonga.miuix.kmp.blur.layerBackdrop
import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop
@@ -197,6 +196,31 @@ private fun AboutContent(
.collect {} .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( BgEffectBackground(
dynamicBackground = true, dynamicBackground = true,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -348,12 +372,7 @@ private fun AboutContent(
.padding(bottom = padding.calculateBottomPadding()), .padding(bottom = padding.calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
AboutCard( AboutCard {
modifier = Modifier.padding(horizontal = 12.dp),
backdrop = backdrop,
blurEnabled = blurEnabled,
blendColors = cardBlendColors,
) {
ArrowPreference( ArrowPreference(
title = "项目仓库", title = "项目仓库",
endActions = { endActions = {
@@ -382,17 +401,15 @@ private fun AboutContent(
}, },
onClick = { onClick = {
context.startActivity( context.startActivity(
Intent(Intent.ACTION_VIEW, releasesUrl.toUri()) Intent(
Intent.ACTION_VIEW,
releasesUrl.toUri(),
)
) )
}, },
) )
} }
AboutCard( AboutCard {
modifier = Modifier.padding(horizontal = 12.dp),
backdrop = backdrop,
blurEnabled = blurEnabled,
blendColors = cardBlendColors,
) {
ArrowPreference( ArrowPreference(
title = "License", title = "License",
endActions = { endActions = {
@@ -406,46 +423,43 @@ private fun AboutContent(
context.startActivity( context.startActivity(
Intent( Intent(
Intent.ACTION_VIEW, 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<BlendColorEntry>,
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<BlendColorEntry> = private fun heroBlendColors(isDark: Boolean): List<BlendColorEntry> =
if (isDark) listOf( if (isDark) listOf(
BlendColorEntry(Color(0xE6A1A1A1), BlurBlendMode.ColorDodge), BlendColorEntry(Color(0xE6A1A1A1), BlurBlendMode.ColorDodge),

View File

@@ -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<String, Int>?,
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<String, Int>? {
return discoverPairingTarget()
}
}

View File

@@ -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<AppSettings.Bundle> = _asBundle.asStateFlow()
private val _qdBundle = MutableStateFlow(quickDevices.bundleState.value)
val soBundle: StateFlow<ScrcpyOptions.Bundle> = scrcpyOptions.bundleState
val scrcpyProfilesState = scrcpyProfiles.state
val connectionState = connectionStateStore.state
val sessionInfo: StateFlow<Scrcpy.Session.SessionInfo?> = scrcpy.currentSessionState
val listingsRefreshBusy: StateFlow<Boolean> = scrcpy.listings.refreshBusyState
val listingsRefreshVersion: StateFlow<Int> = scrcpy.listings.refreshVersionState
private val _busy = MutableStateFlow(false)
val busy: StateFlow<Boolean> = _busy.asStateFlow()
private val _adbConnecting = MutableStateFlow(false)
val adbConnecting: StateFlow<Boolean> = _adbConnecting.asStateFlow()
private val _editingDeviceId = MutableStateFlow<String?>(null)
val editingDeviceId: StateFlow<String?> = _editingDeviceId.asStateFlow()
private val _activeDeviceActionId = MutableStateFlow<String?>(null)
val activeDeviceActionId: StateFlow<String?> = _activeDeviceActionId.asStateFlow()
private val _showRecentTasksSheet = MutableStateFlow(false)
val showRecentTasksSheet: StateFlow<Boolean> = _showRecentTasksSheet.asStateFlow()
private val _showAllAppsSheet = MutableStateFlow(false)
val showAllAppsSheet: StateFlow<Boolean> = _showAllAppsSheet.asStateFlow()
private val _imeRequestToken = MutableStateFlow(0)
val imeRequestToken: StateFlow<Int> = _imeRequestToken.asStateFlow()
private val _pendingScrollToPreview = MutableStateFlow(false)
val pendingScrollToPreview: StateFlow<Boolean> = _pendingScrollToPreview.asStateFlow()
private val _quickConnectInput = MutableStateFlow(_qdBundle.value.quickConnectInput)
val quickConnectInput: StateFlow<String> = _quickConnectInput.asStateFlow()
private val _savedShortcuts = MutableStateFlow(
DeviceShortcuts.unmarshalFrom(_qdBundle.value.quickDevicesList)
)
val savedShortcuts: StateFlow<DeviceShortcuts> = _savedShortcuts.asStateFlow()
private val _isAppInForeground = MutableStateFlow(true)
val isAppInForeground: StateFlow<Boolean> = _isAppInForeground.asStateFlow()
private val sessionReconnectBlacklistHosts = mutableSetOf<String>()
val adbConnected: StateFlow<Boolean> = connectionState
.map { it.adbSession.isConnected }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
connectionState.value.adbSession.isConnected,
)
val statusLine: StateFlow<String> = connectionState
.map { it.adbSession.statusLine }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
connectionState.value.adbSession.statusLine,
)
val isQuickConnected: StateFlow<Boolean> = connectionState
.map { it.adbSession.isQuickConnected }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
connectionState.value.adbSession.isQuickConnected,
)
val currentTarget: StateFlow<ConnectionTarget?> = connectionState
.map { it.adbSession.currentTarget }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
connectionState.value.adbSession.currentTarget,
)
val connectedDeviceLabel: StateFlow<String> = connectionState
.map { it.adbSession.connectedDeviceLabel }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
connectionState.value.adbSession.connectedDeviceLabel,
)
val connectedScrcpyProfileId: StateFlow<String> = 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<ScrcpyOptions.Bundle> = 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<Boolean> = connectedScrcpyBundle
.map { it.video && it.videoPlayback }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
val connectedScrcpyProfileName: StateFlow<String> = 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<Boolean> = 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<Pair<List<VirtualButtonAction>, List<VirtualButtonAction>>> =
_asBundle.map {
VirtualButtonActions.splitLayout(
VirtualButtonActions.parseStoredLayout(it.virtualButtonsLayout)
)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
VirtualButtonActions.splitLayout(
VirtualButtonActions.parseStoredLayout(_asBundle.value.virtualButtonsLayout)
),
)
private val _snackbarEvents = Channel<String>(Channel.BUFFERED)
val snackbarEvents: Flow<String> = _snackbarEvents.receiveAsFlow()
private val _fullscreenRequests = Channel<Unit>(Channel.BUFFERED)
val fullscreenRequests: Flow<Unit> = _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<String, Int>? {
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 <T : ViewModel> create(modelClass: Class<T>): T {
return DeviceTabViewModel(scrcpy, connectionServices) as T
}
}
}

View File

@@ -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<List<String>> = _pathStack.asStateFlow()
val currentPath: StateFlow<String> = _pathStack
.map { it.lastOrNull() ?: INITIAL_REMOTE_PATH }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
INITIAL_REMOTE_PATH,
)
val canNavigateUp: StateFlow<Boolean> = _pathStack
.map { it.size > 1 }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
private val _directoryCache = MutableStateFlow<Map<String, List<RemoteFileEntry>>>(emptyMap())
val directoryCache: StateFlow<Map<String, List<RemoteFileEntry>>> =
_directoryCache.asStateFlow()
val cachedEntries: StateFlow<List<RemoteFileEntry>?> = combine(
_directoryCache, currentPath,
) { cache, path -> cache[path] }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
null,
)
val sortField: StateFlow<FileManagerSortField> = asBundle
.map {
runCatching { FileManagerSortField.valueOf(it.fileManagerSortBy) }.getOrDefault(
FileManagerSortField.NAME
)
}
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
FileManagerSortField.NAME,
)
val sortDescending: StateFlow<Boolean> = asBundle
.map { it.fileManagerSortDescending }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
val displayedEntries: StateFlow<List<RemoteFileEntry>> = combine(
cachedEntries, sortField, sortDescending,
) { entries, field, descending ->
sortEntries(entries.orEmpty(), field, descending)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
emptyList(),
)
private val _directoryScrollCache =
MutableStateFlow(emptyMap<String, FileManagerScrollPosition>())
val directoryScrollCache: StateFlow<Map<String, FileManagerScrollPosition>> =
_directoryScrollCache.asStateFlow()
private val _loading = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading.asStateFlow()
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
private val _errorText = MutableStateFlow<String?>(null)
val errorText: StateFlow<String?> = _errorText.asStateFlow()
private val _showDetailsSheet = MutableStateFlow(false)
val showDetailsSheet: StateFlow<Boolean> = _showDetailsSheet.asStateFlow()
private val _showRawDetails = MutableStateFlow(false)
val showRawDetails: StateFlow<Boolean> = _showRawDetails.asStateFlow()
private val _detailLoading = MutableStateFlow(false)
val detailLoading: StateFlow<Boolean> = _detailLoading.asStateFlow()
private val _selectedEntry = MutableStateFlow<RemoteFileEntry?>(null)
val selectedEntry: StateFlow<RemoteFileEntry?> = _selectedEntry.asStateFlow()
private val _selectedStat = MutableStateFlow<RemoteFileStat?>(null)
val selectedStat: StateFlow<RemoteFileStat?> = _selectedStat.asStateFlow()
private val _selectedTargetStat = MutableStateFlow<RemoteFileStat?>(null)
val selectedTargetStat: StateFlow<RemoteFileStat?> = _selectedTargetStat.asStateFlow()
private val _selectedSnapshot = MutableStateFlow<DirectoryDownloadSnapshot?>(null)
val selectedSnapshot: StateFlow<DirectoryDownloadSnapshot?> = _selectedSnapshot.asStateFlow()
private var activeSnapshotSession: DirectorySnapshotSession? = null
private val _pendingTreeDownload = MutableStateFlow<PendingTreeDownload?>(null)
val pendingTreeDownload: StateFlow<PendingTreeDownload?> = _pendingTreeDownload.asStateFlow()
private val _snackbarEvents = Channel<String>(Channel.BUFFERED)
val snackbarEvents: Flow<String> = _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<String> {
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<RemoteFileEntry>,
field: FileManagerSortField,
descending: Boolean,
): List<RemoteFileEntry> {
val comparator: Comparator<RemoteFileEntry> = when (field) {
FileManagerSortField.NAME -> compareBy { it.name.lowercase() }
FileManagerSortField.SIZE -> compareBy<RemoteFileEntry> {
it.sizeBytes ?: -1L
}.thenBy { it.name.lowercase() }
FileManagerSortField.TIME -> compareBy<RemoteFileEntry> { it.modifiedAt }.thenBy { it.name.lowercase() }
FileManagerSortField.EXTENSION -> compareBy<RemoteFileEntry>(::extensionSortBucket)
.thenBy(::extensionSortKey).thenBy { it.name.lowercase() }
}
return entries.sortedWith(
compareByDescending<RemoteFileEntry> { 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()

View File

@@ -56,6 +56,7 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler 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.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Settings 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() { suspend fun sendBackOrTurnScreenOn() {
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -295,9 +281,7 @@ fun FullscreenControlScreen(
BackHandler(enabled = true) { BackHandler(enabled = true) {
if (asBundle.fullscreenControlBackToDevice && currentSession != null) if (asBundle.fullscreenControlBackToDevice && currentSession != null)
taskScope.launch { taskScope.launch { sendBackOrTurnScreenOn() }
sendBackOrTurnScreenOn()
}
else onBack() 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) { suspend fun commitImeText(text: String) {
submitImeText( submitImeText(
scrcpy = scrcpy, 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( Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbar.hostState) }, snackbarHost = { SnackbarHost(snackbar.hostState) },
@@ -402,9 +430,7 @@ fun FullscreenControlScreen(
} }
}, },
onBackOrScreenOn = { action -> onBackOrScreenOn = { action ->
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { scrcpy.pressBackOrTurnScreenOn(action) }
scrcpy.pressBackOrTurnScreenOn(action)
}
}, },
) )
@@ -421,44 +447,9 @@ fun FullscreenControlScreen(
dock = fullscreenVirtualButtonDock, dock = fullscreenVirtualButtonDock,
reverseOrder = fullscreenVirtualButtonReverseOrder, reverseOrder = fullscreenVirtualButtonReverseOrder,
thickness = fullscreenVirtualButtonHeight, thickness = fullscreenVirtualButtonHeight,
onAction = { action -> onAction = ::handleButtonAction,
when (action) { passwordPopupContent = fragmentActivity?.let {
VirtualButtonAction.RECENT_TASKS -> { { onDismissRequest -> PasswordPickerPopupContent(onDismissRequest = onDismissRequest) }
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,
)
}
}, },
) )
} }
@@ -467,43 +458,10 @@ fun FullscreenControlScreen(
bar.FloatingBall( bar.FloatingBall(
actions = floatingActions, actions = floatingActions,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onAction = { action -> onAction = ::handleButtonAction,
when (action) { passwordPopupContent = fragmentActivity?.let {
VirtualButtonAction.RECENT_TASKS -> { { onDismissRequest -> PasswordPickerPopupContent(onDismissRequest = onDismissRequest) }
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,
)
},
) )
} }
@@ -521,15 +479,7 @@ fun FullscreenControlScreen(
system = app?.system, system = app?.system,
onClick = { onClick = {
showRecentTasksSheet = false showRecentTasksSheet = false
taskScope.launch { taskScope.launch { startApp(task.packageName) }
runCatching {
withContext(Dispatchers.IO) {
scrcpy.startApp(task.packageName)
}
}.onFailure { error ->
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
}
}
}, },
) )
}, },
@@ -556,15 +506,7 @@ fun FullscreenControlScreen(
system = app.system, system = app.system,
onClick = { onClick = {
showAllAppsSheet = false showAllAppsSheet = false
taskScope.launch { taskScope.launch { startApp(app.packageName) }
runCatching {
withContext(Dispatchers.IO) {
scrcpy.startApp(app.packageName)
}
}.onFailure { error ->
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
}
}
}, },
) )
}, },
@@ -576,7 +518,6 @@ fun FullscreenControlScreen(
} }
}, },
) )
} }
} }
} }
@@ -676,22 +617,6 @@ private fun rotateDockDirection(
else -> direction 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 @Composable
fun FullscreenControlPage( fun FullscreenControlPage(
scrcpy: Scrcpy, scrcpy: Scrcpy,

View File

@@ -314,6 +314,11 @@ fun MainScreen() {
autoReconnectManager = autoReconnectManager, autoReconnectManager = autoReconnectManager,
) )
} }
val deviceTabViewModelFactory = remember(scrcpy, deviceConnectionServices) {
DeviceTabViewModel.Factory(scrcpy, deviceConnectionServices)
}
DisposableEffect(deviceConnectionServices) { DisposableEffect(deviceConnectionServices) {
onDispose { onDispose {
deviceConnectionServices.autoReconnectManager.close() deviceConnectionServices.autoReconnectManager.close()
@@ -568,9 +573,8 @@ fun MainScreen() {
saveableStateHolder.SaveableStateProvider(tab.name) { saveableStateHolder.SaveableStateProvider(tab.name) {
when (tab) { when (tab) {
MainBottomTabDestination.Devices -> DeviceTabScreen( MainBottomTabDestination.Devices -> DeviceTabScreen(
viewModelFactory = deviceTabViewModelFactory,
scrollBehavior = devicesPageScrollBehavior, scrollBehavior = devicesPageScrollBehavior,
scrcpy = scrcpy,
connectionServices = deviceConnectionServices,
bottomInnerPadding = bottomInnerPadding, bottomInnerPadding = bottomInnerPadding,
onOpenReorderDevices = { showReorderDevices = true }, onOpenReorderDevices = { showReorderDevices = true },
onPreviewGestureLockChanged = { locked -> onPreviewGestureLockChanged = { locked ->

View File

@@ -46,13 +46,14 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing 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.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.OverlaySpinnerWithFallback
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSpinner
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy 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.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHost 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.TabRow
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton 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.OverlayBottomSheet
import top.yukonga.miuix.kmp.overlay.OverlayDialog import top.yukonga.miuix.kmp.overlay.OverlayDialog
import top.yukonga.miuix.kmp.overlay.OverlayListPopup 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.OverlayDropdownPreference
import top.yukonga.miuix.kmp.preference.SwitchPreference import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
@@ -257,9 +256,10 @@ internal fun ScrcpyAllOptionsScreen(
onDismissRequest = { showProfileMenu = false }, onDismissRequest = { showProfileMenu = false },
) { ) {
ListPopupColumn { ListPopupColumn {
ProfileMenuPopupItem( DropdownImpl(
text = "管理配置", text = "管理配置",
optionSize = 1, optionSize = 1,
isSelected = false,
index = 0, index = 0,
onSelectedIndexChange = { onSelectedIndexChange = {
showManageProfilesSheet = true showManageProfilesSheet = true
@@ -516,9 +516,7 @@ internal fun ScrcpyAllOptionsPage(
(displays.indexOfFirst { it.id == soBundle.displayId } + 1).coerceAtLeast(0) (displays.indexOfFirst { it.id == soBundle.displayId } + 1).coerceAtLeast(0)
} }
val displayOverrideEndActionValue = remember( val displayOverrideEndActionValue = remember(
displays, displays, listRefreshVersion, soBundle.displayId,
listRefreshVersion,
soBundle.displayId,
) { ) {
if (displays.isEmpty() && soBundle.displayId >= 0) soBundle.displayId.toString() if (displays.isEmpty() && soBundle.displayId >= 0) soBundle.displayId.toString()
else null else null
@@ -550,9 +548,7 @@ internal fun ScrcpyAllOptionsPage(
(cameras.indexOfFirst { it.id == soBundle.cameraId } + 1).coerceAtLeast(0) (cameras.indexOfFirst { it.id == soBundle.cameraId } + 1).coerceAtLeast(0)
} }
val cameraOverrideEndActionValue = remember( val cameraOverrideEndActionValue = remember(
cameras, cameras, listRefreshVersion, soBundle.cameraId,
listRefreshVersion,
soBundle.cameraId,
) { ) {
if (cameras.isEmpty() && soBundle.cameraId.isNotBlank()) soBundle.cameraId if (cameras.isEmpty() && soBundle.cameraId.isNotBlank()) soBundle.cameraId
else null else null
@@ -601,11 +597,8 @@ internal fun ScrcpyAllOptionsPage(
} }
} }
val cameraSizeOverrideEndActionValue = remember( val cameraSizeOverrideEndActionValue = remember(
cameraSizes, cameraSizes, listRefreshVersion,
listRefreshVersion, soBundle.cameraSize, soBundle.cameraSizeCustom, soBundle.cameraSizeUseCustom,
soBundle.cameraSize,
soBundle.cameraSizeCustom,
soBundle.cameraSizeUseCustom,
) { ) {
when { when {
cameraSizes.isNotEmpty() -> null cameraSizes.isNotEmpty() -> null
@@ -689,9 +682,7 @@ internal fun ScrcpyAllOptionsPage(
.coerceAtLeast(0) .coerceAtLeast(0)
} }
val videoEncoderOverrideEndActionValue = remember( val videoEncoderOverrideEndActionValue = remember(
videoEncoders, videoEncoders, listRefreshVersion, soBundle.videoEncoder,
listRefreshVersion,
soBundle.videoEncoder,
) { ) {
if (videoEncoders.isEmpty() && soBundle.videoEncoder.isNotBlank()) soBundle.videoEncoder if (videoEncoders.isEmpty() && soBundle.videoEncoder.isNotBlank()) soBundle.videoEncoder
else null else null
@@ -722,9 +713,7 @@ internal fun ScrcpyAllOptionsPage(
.coerceAtLeast(0) .coerceAtLeast(0)
} }
val audioEncoderOverrideEndActionValue = remember( val audioEncoderOverrideEndActionValue = remember(
audioEncoders, audioEncoders, listRefreshVersion, soBundle.audioEncoder,
listRefreshVersion,
soBundle.audioEncoder,
) { ) {
if (audioEncoders.isEmpty() && soBundle.audioEncoder.isNotBlank()) soBundle.audioEncoder if (audioEncoders.isEmpty() && soBundle.audioEncoder.isNotBlank()) soBundle.audioEncoder
else null else null
@@ -792,13 +781,12 @@ internal fun ScrcpyAllOptionsPage(
} }
} }
val startAppOverrideEndActionValue = remember( val startAppOverrideEndActionValue = remember(
apps, apps, listRefreshVersion, soBundle.startApp,
listRefreshVersion,
soBundle.startApp,
) { ) {
if (apps.isEmpty() && soBundle.startApp.isNotBlank()) soBundle.startApp if (apps.isEmpty() && soBundle.startApp.isNotBlank()) soBundle.startApp
else null else null
} }
var startAppCustomInput by rememberSaveable(soBundle.startAppCustom) { var startAppCustomInput by rememberSaveable(soBundle.startAppCustom) {
mutableStateOf(soBundle.startAppCustom) mutableStateOf(soBundle.startAppCustom)
} }
@@ -1204,30 +1192,29 @@ internal fun ScrcpyAllOptionsPage(
) )
AnimatedVisibility(soBundle.videoSource == "display") { AnimatedVisibility(soBundle.videoSource == "display") {
Column { Column {
ArrowPreference( OverlaySpinnerWithFallback(
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(
title = "监视器 ID", title = "监视器 ID",
summary = "--display-id", summary = "--display-id",
items = displaySpinnerItems, items = displaySpinnerItems,
selectedIndex = displayDropdownIndex, selectedIndex = displayDropdownIndex,
dataLoaded = displays.isNotEmpty(),
dataLoading = refreshBusy,
overrideEndActionValue = displayOverrideEndActionValue, 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 = { onSelectedIndexChange = {
soBundle = soBundle.copy( soBundle = soBundle.copy(
displayId = displayId =
@@ -1301,30 +1288,29 @@ internal fun ScrcpyAllOptionsPage(
} }
AnimatedVisibility(soBundle.videoSource == "camera") { AnimatedVisibility(soBundle.videoSource == "camera") {
Column { Column {
ArrowPreference( OverlaySpinnerWithFallback(
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(
title = "摄像头 ID", title = "摄像头 ID",
summary = "--camera-id", summary = "--camera-id",
items = cameraSpinnerItems, items = cameraSpinnerItems,
selectedIndex = cameraDropdownIndex, selectedIndex = cameraDropdownIndex,
dataLoaded = cameras.isNotEmpty(),
dataLoading = refreshBusy,
overrideEndActionValue = cameraOverrideEndActionValue, 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 = { onSelectedIndexChange = {
soBundle = soBundle.copy( soBundle = soBundle.copy(
cameraId = cameraId =
@@ -1346,30 +1332,29 @@ internal fun ScrcpyAllOptionsPage(
) )
}, },
) )
ArrowPreference( OverlaySpinnerWithFallback(
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(
title = "摄像头分辨率", title = "摄像头分辨率",
summary = "--camera-size", summary = "--camera-size",
items = cameraSizeSpinnerItems, items = cameraSizeSpinnerItems,
selectedIndex = cameraSizeDropdownIndex, selectedIndex = cameraSizeDropdownIndex,
dataLoaded = cameraSizes.isNotEmpty(),
dataLoading = refreshBusy,
overrideEndActionValue = cameraSizeOverrideEndActionValue, 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 = { onSelectedIndexChange = {
when (it) { when (it) {
0 -> { 0 -> {
@@ -1524,31 +1509,29 @@ internal fun ScrcpyAllOptionsPage(
item { item {
Card { Card {
ArrowPreference( OverlaySpinnerWithFallback(
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(
title = "视频编码器", title = "视频编码器",
summary = "--video-encoder", summary = "--video-encoder",
items = videoEncoderItems, items = videoEncoderItems,
selectedIndex = videoEncoderIndex, selectedIndex = videoEncoderIndex,
dataLoaded = videoEncoders.isNotEmpty(),
dataLoading = refreshBusy,
overrideEndActionValue = videoEncoderOverrideEndActionValue, 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 = { onSelectedIndexChange = {
soBundle = soBundle.copy( soBundle = soBundle.copy(
videoEncoder = videoEncoder =
@@ -1571,12 +1554,29 @@ internal fun ScrcpyAllOptionsPage(
.fillMaxWidth() .fillMaxWidth()
.padding(all = UiSpacing.Large), .padding(all = UiSpacing.Large),
) )
SuperSpinner( OverlaySpinnerWithFallback(
title = "音频编码器", title = "音频编码器",
summary = "--audio-encoder", summary = "--audio-encoder",
items = audioEncoderItems, items = audioEncoderItems,
selectedIndex = audioEncoderIndex, selectedIndex = audioEncoderIndex,
dataLoaded = audioEncoders.isNotEmpty(),
dataLoading = refreshBusy,
overrideEndActionValue = audioEncoderOverrideEndActionValue, 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 = { onSelectedIndexChange = {
soBundle = soBundle.copy( soBundle = soBundle.copy(
audioEncoder = audioEncoder =
@@ -1734,30 +1734,29 @@ internal fun ScrcpyAllOptionsPage(
if (soBundle.videoSource == "display") item { if (soBundle.videoSource == "display") item {
Card { Card {
ArrowPreference( OverlaySpinnerWithFallback(
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(
title = "scrcpy 启动后打开应用", title = "scrcpy 启动后打开应用",
summary = "--start-app", summary = "--start-app",
items = appDropdownItems, items = appDropdownItems,
selectedIndex = appDropdownIndex, selectedIndex = appDropdownIndex,
dataLoaded = apps.isNotEmpty(),
dataLoading = refreshBusy,
overrideEndActionValue = startAppOverrideEndActionValue, 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 = { onSelectedIndexChange = {
when (it) { when (it) {
0 -> { 0 -> {
@@ -2112,39 +2111,6 @@ private enum class ProfileDialogMode {
Rename, 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 @Composable
private fun ProfileNameDialog( private fun ProfileNameDialog(
mode: ProfileDialogMode?, mode: ProfileDialogMode?,

View File

@@ -51,8 +51,6 @@ import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.MonetKeyColorOptions import io.github.miuzarte.scrcpyforandroid.ui.MonetKeyColorOptions
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -60,6 +58,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Card 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.Icon
import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Scaffold
@@ -481,12 +481,11 @@ fun SettingsPage(
) )
AnimatedVisibility(asBundle.showFullscreenVirtualButtons) { AnimatedVisibility(asBundle.showFullscreenVirtualButtons) {
Column { Column {
MultiGroupsDropdownPreference( OverlayDropdownPreference(
title = "虚拟按钮方向", entries = listOf(
summary = fullscreenVirtualButtonDock.summary, DropdownEntry(
groups = listOf( items = FullscreenVirtualButtonDock
MultiGroupsDropdownGroup( .modeItems.map { DropdownItem(it) },
options = FullscreenVirtualButtonDock.modeItems,
selectedIndex = fullscreenVirtualButtonDock.modeIndex, selectedIndex = fullscreenVirtualButtonDock.modeIndex,
onSelectedIndexChange = { modeIndex -> onSelectedIndexChange = { modeIndex ->
asBundle = asBundle.copy( asBundle = asBundle.copy(
@@ -499,8 +498,9 @@ fun SettingsPage(
) )
}, },
), ),
MultiGroupsDropdownGroup( DropdownEntry(
options = FullscreenVirtualButtonDock.directionItems, items = FullscreenVirtualButtonDock
.directionItems.map { DropdownItem(it) },
selectedIndex = fullscreenVirtualButtonDock.directionIndex, selectedIndex = fullscreenVirtualButtonDock.directionIndex,
onSelectedIndexChange = { directionIndex -> onSelectedIndexChange = { directionIndex ->
asBundle = asBundle.copy( asBundle = asBundle.copy(
@@ -514,6 +514,8 @@ fun SettingsPage(
}, },
), ),
), ),
title = "虚拟按钮方向",
summary = fullscreenVirtualButtonDock.summary,
) )
SuperSlider( SuperSlider(
title = "虚拟按钮高度", title = "虚拟按钮高度",

View File

@@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -44,38 +43,24 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView 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.TerminalSession
import com.termux.terminal.TextStyle
import com.termux.view.TerminalView import com.termux.view.TerminalView
import com.termux.view.TerminalViewClient import com.termux.view.TerminalViewClient
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing 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.LocalInputService
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController 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.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.contextClick import io.github.miuzarte.scrcpyforandroid.ui.contextClick
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.PopupMenuItem import top.yukonga.miuix.kmp.basic.DropdownImpl
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.Icon import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.ListPopupColumn import top.yukonga.miuix.kmp.basic.ListPopupColumn
import top.yukonga.miuix.kmp.basic.ListPopupDefaults import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar 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.Text
import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.blur.layerBackdrop 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.overlay.OverlayListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import java.io.File import java.io.File
import java.nio.charset.StandardCharsets
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max 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 FONT_SCALE_STEP_THRESHOLD = 1.08f
private const val LOG_TAG = "TerminalScreen" private const val LOG_TAG = "TerminalScreen"
private const val TERMINAL_FONT_RELATIVE_PATH = "terminal/font.ttf"
private fun terminalFontFile(context: android.content.Context): File { private fun terminalFontFile(context: android.content.Context) =
return File(context.filesDir, TERMINAL_FONT_RELATIVE_PATH) File(context.filesDir, "terminal/font.ttf")
}
private fun loadTerminalTypeface(context: android.content.Context): Typeface? { private fun loadTerminalTypeface(context: android.content.Context): Typeface? {
val file = terminalFontFile(context) val file = terminalFontFile(context)
@@ -113,6 +91,7 @@ fun TerminalScreen(
isActive: Boolean, isActive: Boolean,
onTerminalGestureLockChanged: (Boolean) -> Unit, onTerminalGestureLockChanged: (Boolean) -> Unit,
) { ) {
val viewModel: TerminalViewModel = viewModel()
val context = LocalContext.current val context = LocalContext.current
val snackbar = LocalSnackbarController.current val snackbar = LocalSnackbarController.current
val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current) val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current)
@@ -121,6 +100,10 @@ fun TerminalScreen(
var showOutputSheet by rememberSaveable { mutableStateOf(false) } var showOutputSheet by rememberSaveable { mutableStateOf(false) }
var output by rememberSaveable { mutableStateOf("") } var output by rememberSaveable { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.snackbarEvents.collect { snackbar.show(it) }
}
Scaffold( Scaffold(
topBar = { topBar = {
BlurredBar(backdrop = blurBackdrop) { BlurredBar(backdrop = blurBackdrop) {
@@ -144,9 +127,10 @@ fun TerminalScreen(
onDismissRequest = { showMenu = false }, onDismissRequest = { showMenu = false },
) { ) {
ListPopupColumn { ListPopupColumn {
PopupMenuItem( DropdownImpl(
text = "自由复制", text = "自由复制",
optionSize = 2, optionSize = 2,
isSelected = false,
index = 0, index = 0,
enabled = output.isNotBlank(), enabled = output.isNotBlank(),
onSelectedIndexChange = { onSelectedIndexChange = {
@@ -154,9 +138,10 @@ fun TerminalScreen(
showOutputSheet = true showOutputSheet = true
}, },
) )
PopupMenuItem( DropdownImpl(
text = "清屏", text = "清屏",
optionSize = 2, optionSize = 2,
isSelected = false,
index = 1, index = 1,
onSelectedIndexChange = { onSelectedIndexChange = {
showMenu = false showMenu = false
@@ -174,9 +159,10 @@ fun TerminalScreen(
Box( Box(
modifier = modifier =
if (blurActive) Modifier.layerBackdrop(blurBackdrop) if (blurActive) Modifier.layerBackdrop(blurBackdrop)
else Modifier, else Modifier
) { ) {
TerminalPage( TerminalPage(
viewModel = viewModel,
contentPadding = pagePadding, contentPadding = pagePadding,
bottomInnerPadding = bottomInnerPadding, bottomInnerPadding = bottomInnerPadding,
isActive = isActive, isActive = isActive,
@@ -184,7 +170,6 @@ fun TerminalScreen(
output = output, output = output,
onOutputChange = { output = it }, onOutputChange = { output = it },
) )
TerminalOutputBottomSheet( TerminalOutputBottomSheet(
show = showOutputSheet, show = showOutputSheet,
output = output, output = output,
@@ -202,6 +187,7 @@ fun TerminalScreen(
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
@Composable @Composable
private fun TerminalPage( private fun TerminalPage(
viewModel: TerminalViewModel,
contentPadding: PaddingValues, contentPadding: PaddingValues,
bottomInnerPadding: Dp, bottomInnerPadding: Dp,
isActive: Boolean, isActive: Boolean,
@@ -213,151 +199,81 @@ private fun TerminalPage(
val density = LocalDensity.current val density = LocalDensity.current
val snackbar = LocalSnackbarController.current val snackbar = LocalSnackbarController.current
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val asBundleShared by appSettings.bundleState.collectAsState()
val uiScope = rememberCoroutineScope() val asBundle by viewModel.asBundle.collectAsState()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val terminalFontSizeSp by viewModel.terminalFontSizeSp.collectAsState()
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<AdbSocketStream?>(null) }
var terminalView by remember { mutableStateOf<TerminalView?>(null) }
val imeBottomDp = with(density) { WindowInsets.ime.getBottom(this).toDp() } 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 pinchGestureLock by remember { mutableStateOf(false) }
var terminalTouchStartX by remember { mutableFloatStateOf(0f) } var terminalTouchStartX by remember { mutableFloatStateOf(0f) }
var terminalTouchStartY by remember { mutableFloatStateOf(0f) } var terminalTouchStartY by remember { mutableFloatStateOf(0f) }
val terminalTouchSlop = remember(context) { val terminalTouchSlop = remember(context) { ViewConfiguration.get(context).scaledTouchSlop }
ViewConfiguration.get(context).scaledTouchSlop
}
var customTypeface by remember { mutableStateOf(loadTerminalTypeface(context)) } var customTypeface by remember { mutableStateOf(loadTerminalTypeface(context)) }
val sessionHolder = remember { arrayOfNulls<TerminalSession>(1) } var terminalView by remember { mutableStateOf<TerminalView?>(null) }
val terminalSurfaceColorArgb = colorScheme.surface.toArgb() val terminalSurfaceColorArgb = colorScheme.surface.toArgb()
val terminalOnSurfaceColorArgb = colorScheme.onSurface.toArgb() val terminalOnSurfaceColorArgb = colorScheme.onSurface.toArgb()
val terminalCursorColorArgb = val terminalCursorColorArgb =
if (colorScheme.surface.luminance() < 0.5f) 0xffffffff.toInt() if (colorScheme.surface.luminance() < 0.5f) 0xffffffff.toInt()
else 0xff000000.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 { val terminalSession = remember {
TerminalSession( TerminalSession(
shellWriter = ::writeBytesToShell, shellWriter = { data, offset, count ->
onScreenUpdated = ::syncOutput, viewModel.writeBytesToShell(data, offset, count)
},
onScreenUpdated = {
viewModel.syncOutput { onOutputChange(it) }
terminalView?.onScreenUpdated()
},
onCopyTextToClipboardRequested = { text -> onCopyTextToClipboardRequested = { text ->
LocalInputService.setClipboardText(context, text) LocalInputService.setClipboardText(context, text)
uiScope.launch { snackbar.show("已复制到剪贴板")
snackbar.show("已复制到剪贴板")
}
}, },
onPasteTextFromClipboardRequested = ::writeClipboardToShell, onPasteTextFromClipboardRequested = { viewModel.writeClipboardToShell(context) },
onBellRequested = {}, onBellRequested = {},
) ).also { viewModel.sessionHolder[0] = it }
}
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
} }
fun updateTerminalFontSize(newValue: Float) { fun requestTerminalFocus() {
val clamped = newValue.coerceIn(MIN_TERMINAL_FONT_SIZE_SP, MAX_TERMINAL_FONT_SIZE_SP) terminalView?.requestFocusFromTouch()
if (clamped == terminalFontSizeSp) { terminalView?.requestFocus()
return terminalView?.post {
terminalView?.requestFocusFromTouch()
terminalView?.requestFocus()
terminalView?.let(LocalInputService::showSoftKeyboard)
} }
terminalFontSizeSp = clamped }
terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
uiScope.launch { fun openShellSession(showKeyboardAfterConnect: Boolean) {
val latest = appSettings.bundleState.value viewModel.openShellSession(showKeyboardAfterConnect, ::requestTerminalFocus)
if (latest.terminalFontSizeSp != clamped) { }
appSettings.updateBundle {
it.copy(terminalFontSizeSp = clamped) fun updateFontSize(newValue: Float) {
} viewModel.updateTerminalFontSize(newValue) { clamped ->
} terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
} }
} }
fun showFontSizeSnackbar() { fun showFontSizeSnackbar() {
uiScope.launch { viewModel.launchFontSizeSnackbar(
snackbar.hostState.newestSnackbarData()?.dismiss() fontSizeSp = terminalFontSizeSp,
val result = snackbar.hostState.showSnackbar( hostState = snackbar.hostState,
message = "终端字号 ${terminalFontSizeSp.roundToInt()}sp", onReset = { clamped ->
actionLabel = "恢复默认", terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
withDismissAction = true, },
duration = SnackbarDuration.Short, )
)
if (result == SnackbarResult.ActionPerformed) {
updateTerminalFontSize(DEFAULT_TERMINAL_FONT_SIZE_SP)
}
}
} }
fun handleTerminalTouchInterception( fun applyTheme() {
view: TerminalView, viewModel.applyTerminalThemeColors(
event: MotionEvent, terminalSurfaceColorArgb,
): Boolean { terminalOnSurfaceColorArgb,
terminalCursorColorArgb,
)
}
fun handleTerminalTouchInterception(view: TerminalView, event: MotionEvent): Boolean {
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
terminalTouchStartX = event.x terminalTouchStartX = event.x
@@ -384,7 +300,6 @@ private fun TerminalPage(
} }
} }
} }
val shouldUnlock = event.actionMasked == MotionEvent.ACTION_UP || val shouldUnlock = event.actionMasked == MotionEvent.ACTION_UP ||
event.actionMasked == MotionEvent.ACTION_CANCEL || event.actionMasked == MotionEvent.ACTION_CANCEL ||
(event.actionMasked == MotionEvent.ACTION_POINTER_UP && event.pointerCount <= 2) (event.actionMasked == MotionEvent.ACTION_POINTER_UP && event.pointerCount <= 2)
@@ -398,143 +313,18 @@ private fun TerminalPage(
return false 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 { val terminalViewClient = remember {
object : TerminalViewClient { object : TerminalViewClient {
override fun onScale(scale: Float): Float { override fun onScale(scale: Float): Float {
when { when {
scale >= FONT_SCALE_STEP_THRESHOLD -> { scale >= FONT_SCALE_STEP_THRESHOLD -> {
updateTerminalFontSize(terminalFontSizeSp + 1f) updateFontSize(terminalFontSizeSp + 1f)
showFontSizeSnackbar() showFontSizeSnackbar()
return 1f return 1f
} }
scale <= 1f / FONT_SCALE_STEP_THRESHOLD -> { scale <= 1f / FONT_SCALE_STEP_THRESHOLD -> {
updateTerminalFontSize(terminalFontSizeSp - 1f) updateFontSize(terminalFontSizeSp - 1f)
showFontSizeSnackbar() showFontSizeSnackbar()
return 1f return 1f
} }
@@ -543,55 +333,42 @@ private fun TerminalPage(
} }
override fun onSingleTapUp(e: MotionEvent) { override fun onSingleTapUp(e: MotionEvent) {
openShellSession(showKeyboardAfterConnect = true) openShellSession(true)
} }
override fun shouldBackButtonBeMappedToEscape(): Boolean = false override fun shouldBackButtonBeMappedToEscape() = false
override fun shouldEnforceCharBasedInput() = false
override fun shouldEnforceCharBasedInput(): Boolean = false override fun shouldUseCtrlSpaceWorkaround() = false
override fun isTerminalViewSelected() = true
override fun shouldUseCtrlSpaceWorkaround(): Boolean = false
override fun isTerminalViewSelected(): Boolean = true
override fun copyModeChanged(copyMode: Boolean) = Unit override fun copyModeChanged(copyMode: Boolean) = Unit
override fun onKeyDown( override fun onKeyDown(
keyCode: Int, keyCode: Int,
e: KeyEvent, e: KeyEvent,
session: TerminalSession, session: TerminalSession,
): Boolean { ): Boolean {
if (e.action == KeyEvent.ACTION_DOWN && (ctrlLatched || altLatched)) { if (e.action == KeyEvent.ACTION_DOWN && (viewModel.ctrlLatched || viewModel.altLatched))
pendingLatchedConsume = true viewModel.pendingLatchedConsume = true
}
return false return false
} }
override fun onKeyUp(keyCode: Int, e: KeyEvent): Boolean { override fun onKeyUp(keyCode: Int, e: KeyEvent): Boolean {
if (e.action == KeyEvent.ACTION_UP && pendingLatchedConsume) { if (e.action == KeyEvent.ACTION_UP && viewModel.pendingLatchedConsume)
consumeLatchedVisualState() viewModel.consumeLatchedVisualState()
}
return false return false
} }
override fun onLongPress(event: MotionEvent): Boolean = false override fun onLongPress(event: MotionEvent) = false
override fun readControlKey() = viewModel.ctrlLatched
override fun readControlKey(): Boolean = ctrlLatched override fun readAltKey() = viewModel.altLatched
override fun readShiftKey() = false
override fun readAltKey(): Boolean = altLatched override fun readFnKey() = false
override fun readShiftKey(): Boolean = false
override fun readFnKey(): Boolean = false
override fun onCodePoint( override fun onCodePoint(
codePoint: Int, codePoint: Int,
ctrlDown: Boolean, ctrlDown: Boolean,
session: TerminalSession, session: TerminalSession,
): Boolean { ): Boolean {
if (ctrlLatched || altLatched || pendingLatchedConsume) { if (viewModel.ctrlLatched || viewModel.altLatched || viewModel.pendingLatchedConsume)
consumeLatchedVisualState() viewModel.consumeLatchedVisualState()
}
return false return false
} }
@@ -601,7 +378,7 @@ private fun TerminalPage(
start = true, start = true,
startOnlyIfCursorEnabled = true startOnlyIfCursorEnabled = true
) )
syncOutput() viewModel.syncOutput { onOutputChange(it) }
} }
override fun logError(tag: String?, message: String?) { override fun logError(tag: String?, message: String?) {
@@ -624,11 +401,7 @@ private fun TerminalPage(
Log.v(tag ?: LOG_TAG, message.orEmpty()) Log.v(tag ?: LOG_TAG, message.orEmpty())
} }
override fun logStackTraceWithMessage( override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) {
tag: String?,
message: String?,
e: Exception?,
) {
Log.e(tag ?: LOG_TAG, message, e) Log.e(tag ?: LOG_TAG, message, e)
} }
@@ -638,50 +411,48 @@ private fun TerminalPage(
} }
} }
// reset on clear output
LaunchedEffect(output) { LaunchedEffect(output) {
if (output.isEmpty() && extractTranscript(terminalSession).isNotEmpty()) { if (output.isEmpty() && viewModel.extractTranscript(terminalSession).isNotEmpty()) {
terminalSession.reset() terminalSession.reset()
applyTerminalThemeColors() applyTheme()
terminalView?.mEmulator = terminalSession.emulator terminalView?.mEmulator = terminalSession.emulator
terminalView?.onScreenUpdated() terminalView?.onScreenUpdated()
syncOutput() viewModel.syncOutput { onOutputChange(it) }
} }
} }
// auto-connect when tab becomes active
LaunchedEffect(isActive) { LaunchedEffect(isActive) {
if (!isActive) { if (!isActive) {
onTerminalGestureLockChanged(false) onTerminalGestureLockChanged(false)
return@LaunchedEffect return@LaunchedEffect
} }
customTypeface = loadTerminalTypeface(context) customTypeface = loadTerminalTypeface(context)
if (shellStream == null && !shellConnecting) { viewModel.autoConnectIfNeeded(::requestTerminalFocus)
val connected = runCatching { NativeAdbService.isConnected() }.getOrDefault(false)
if (connected) {
openShellSession(showKeyboardAfterConnect = false)
}
}
} }
LaunchedEffect(colorScheme.surface, colorScheme.onSurface) { // theme changes
applyTerminalThemeColors() LaunchedEffect(
colorScheme.surface,
colorScheme.onSurface
) {
applyTheme()
terminalView?.onScreenUpdated() terminalView?.onScreenUpdated()
syncOutput() viewModel.syncOutput { onOutputChange(it) }
}
// font size sync from storage
LaunchedEffect(asBundle.terminalFontSizeSp) {
if (terminalFontSizeSp != asBundle.terminalFontSizeSp) {
updateFontSize(asBundle.terminalFontSizeSp)
}
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
val latestFontSize = terminalFontSizeSp
onTerminalGestureLockChanged(false) onTerminalGestureLockChanged(false)
taskScope.launch { viewModel.closeShell()
val latest = appSettings.bundleState.value
if (latest.terminalFontSizeSp != latestFontSize) {
appSettings.updateBundle {
it.copy(terminalFontSizeSp = latestFontSize)
}
}
}
runCatching { shellStream?.close() }
taskScope.cancel()
} }
} }
@@ -708,15 +479,10 @@ private fun TerminalPage(
setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() }) setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() })
setTypeface(customTypeface ?: Typeface.MONOSPACE) setTypeface(customTypeface ?: Typeface.MONOSPACE)
attachSession(terminalSession) attachSession(terminalSession)
applyTerminalThemeColors() applyTheme()
setTerminalCursorBlinkerRate(500) setTerminalCursorBlinkerRate(500)
setTerminalCursorBlinkerState( setTerminalCursorBlinkerState(start = true, startOnlyIfCursorEnabled = true)
start = true, setOnTouchListener { _, event -> handleTerminalTouchInterception(this, event) }
startOnlyIfCursorEnabled = true,
)
setOnTouchListener { _, event ->
handleTerminalTouchInterception(this, event)
}
terminalView = this terminalView = this
} }
}, },
@@ -724,126 +490,118 @@ private fun TerminalPage(
terminalView = it terminalView = it
it.setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() }) it.setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() })
it.setTypeface(customTypeface ?: Typeface.MONOSPACE) it.setTypeface(customTypeface ?: Typeface.MONOSPACE)
if (it.currentSession !== terminalSession) { if (it.currentSession !== terminalSession) it.attachSession(terminalSession)
it.attachSession(terminalSession) it.setOnTouchListener { _, event -> handleTerminalTouchInterception(it, event) }
} applyTheme()
it.setOnTouchListener { _, event ->
handleTerminalTouchInterception(it, event)
}
applyTerminalThemeColors()
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
) )
Row( Row(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth(),
) {
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "ESC", "ESC",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeLiteralKey("\u001B") viewModel.writeLiteralKey("")
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "/", "/",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeLiteralKey("/") viewModel.writeLiteralKey("/")
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "-", "-",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeLiteralKey("-") viewModel.writeLiteralKey("-")
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "HOME", "HOME",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeSpecialKey(KeyEvent.KEYCODE_MOVE_HOME) viewModel.writeSpecialKey(KeyEvent.KEYCODE_MOVE_HOME)
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "", "",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeSpecialKey(KeyEvent.KEYCODE_DPAD_UP) viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_UP)
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "END", "END",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeSpecialKey(KeyEvent.KEYCODE_MOVE_END) viewModel.writeSpecialKey(KeyEvent.KEYCODE_MOVE_END)
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "PGUP", "PGUP",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeSpecialKey(KeyEvent.KEYCODE_PAGE_UP) viewModel.writeSpecialKey(KeyEvent.KEYCODE_PAGE_UP)
} }
} }
Row( Row(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth(),
) {
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "TAB", "TAB",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeSpecialKey(KeyEvent.KEYCODE_TAB) viewModel.writeSpecialKey(KeyEvent.KEYCODE_TAB)
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "CTRL", "CTRL",
active = ctrlLatched, Modifier.weight(1f),
modifier = Modifier.weight(1f), active = viewModel.ctrlLatched
) { ) {
haptic.contextClick() haptic.contextClick()
ctrlLatched = !ctrlLatched viewModel.ctrlLatched = !viewModel.ctrlLatched
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "ALT", "ALT",
active = altLatched, Modifier.weight(1f),
modifier = Modifier.weight(1f), active = viewModel.altLatched
) { ) {
haptic.contextClick() haptic.contextClick()
altLatched = !altLatched viewModel.altLatched = !viewModel.altLatched
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "", "",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeSpecialKey(KeyEvent.KEYCODE_DPAD_LEFT) viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_LEFT)
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "", "",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeSpecialKey(KeyEvent.KEYCODE_DPAD_DOWN) viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_DOWN)
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "", "",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeSpecialKey(KeyEvent.KEYCODE_DPAD_RIGHT) viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_RIGHT)
} }
TerminalExtraKeyButton( TerminalExtraKeyButton(
label = "PGDN", "PGDN",
modifier = Modifier.weight(1f), Modifier.weight(1f),
) { ) {
haptic.contextClick() haptic.contextClick()
writeSpecialKey(KeyEvent.KEYCODE_PAGE_DOWN) viewModel.writeSpecialKey(KeyEvent.KEYCODE_PAGE_DOWN)
} }
} }
} }
@@ -859,7 +617,6 @@ private fun TerminalExtraKeyButton(
val content = val content =
if (active) colorScheme.primary if (active) colorScheme.primary
else colorScheme.onSurface else colorScheme.onSurface
Box( Box(
modifier = modifier modifier = modifier
.height(32.dp) .height(32.dp)
@@ -898,7 +655,7 @@ private fun TerminalOutputBottomSheet(
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(2f / 3f), .fillMaxHeight(2f / 3f)
) { ) {
item { item {
TextField( TextField(

View File

@@ -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<Float> = _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<Boolean> = _shellReady.asStateFlow()
private val _shellConnecting = MutableStateFlow(false)
val shellConnecting: StateFlow<Boolean> = _shellConnecting.asStateFlow()
private val shellWriteMutex = Mutex()
private var shellStream: AdbSocketStream? = null
val sessionHolder = arrayOfNulls<TerminalSession>(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<String>(Channel.BUFFERED)
val snackbarEvents: Flow<String> = _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
}
}
}

View File

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

View File

@@ -18,7 +18,6 @@ data class ClientOptions(
// --crop=width:height:x:y // --crop=width:height:x:y
var crop: String = "", // to server var crop: String = "", // to server
// TODO: implement
// --record // --record
var recordFilename: String = "", var recordFilename: String = "",

View File

@@ -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<T>(
private val sharedFlow: StateFlow<T>,
private val save: suspend (T) -> Unit,
private val scope: CoroutineScope,
private val delayMs: Long = 600L,
) {
private val _value = MutableStateFlow(sharedFlow.value)
val value: StateFlow<T> = _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 <T> rememberBundleState(
sharedFlow: StateFlow<T>,
save: suspend (T) -> Unit,
): MutableState<T> {
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
}

View File

@@ -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<String>,
val selectedIndex: Int,
val onSelectedIndexChange: (Int) -> Unit,
)
@Composable
fun MultiGroupsDropdownPreference(
title: String,
modifier: Modifier = Modifier,
summary: String? = null,
groups: List<MultiGroupsDropdownGroup>,
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<MultiGroupsDropdownGroup>,
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))
}
}
}

View File

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

View File

@@ -27,6 +27,7 @@ androidxSecurityCrypto = "1.1.0"
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } 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-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-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-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }

View File

@@ -29,4 +29,5 @@ dependencyResolutionManagement {
} }
rootProject.name = "Scrcpy" rootProject.name = "Scrcpy"
includeBuild("submodule/miuix")
include(":app") include(":app")

1
submodule/miuix Submodule

Submodule submodule/miuix added at 81ad71b1c6