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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,6 +34,7 @@ google-services.json
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
.claude/
|
||||
.kiro/
|
||||
|
||||
.kotlin/
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "submodule/miuix"]
|
||||
path = submodule/miuix
|
||||
url = https://github.com/compose-miuix-ui/miuix.git
|
||||
@@ -1,6 +1,12 @@
|
||||
# Change Log
|
||||
|
||||
~~什么我居然开始写更新日志了~~
|
||||
## 0.3.0
|
||||
|
||||
该版本主要进行代码重构,引入 ViewModel 以优化代码结构
|
||||
|
||||
- 重构: 部分自行实现的组件迁移至 miuix
|
||||
- 引入子模块以在本地编译 miuix 的最新提交
|
||||
- 新增: 所有参数页中的五个列表展开即触发懒加载
|
||||
|
||||
## 0.2.9
|
||||
|
||||
|
||||
@@ -128,9 +128,17 @@
|
||||
- Android NDK `29.0.14206865`
|
||||
|
||||
```bash
|
||||
git clone --recursive https://github.com/Miuzarte/ScrcpyForAndroid.git
|
||||
cd ScrcpyForAndroid
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
已克隆但没有拉取子模块:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
specific abi:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -126,6 +126,7 @@ androidComponents {
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.core.pip)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.scaffolds
|
||||
package io.github.miuzarte.scrcpyforandroid.miuix
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -26,14 +26,17 @@ import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||
import top.yukonga.miuix.kmp.basic.SpinnerColors
|
||||
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
|
||||
import top.yukonga.miuix.kmp.basic.SpinnerEntry
|
||||
import top.yukonga.miuix.kmp.basic.SpinnerItemImpl
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* Copy of miuix [OverlaySpinnerPreference] (popup mode) using our custom
|
||||
* [SpinnerEntry] with per-item [SpinnerEntry.enabled] passed through to
|
||||
* [SpinnerItemImpl].
|
||||
*/
|
||||
@Composable
|
||||
fun SuperSpinner(
|
||||
fun OverlaySpinnerPreference(
|
||||
items: List<SpinnerEntry>,
|
||||
selectedIndex: Int,
|
||||
title: String,
|
||||
@@ -49,21 +52,26 @@ fun SuperSpinner(
|
||||
enabled: Boolean = true,
|
||||
showValue: Boolean = true,
|
||||
renderInRootScaffold: Boolean = true,
|
||||
onExpandedChange: ((Boolean) -> Unit)? = null,
|
||||
onSelectedIndexChange: ((Int) -> Unit)? = null,
|
||||
overrideEndActionValue: String? = null,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isDropdownExpanded = rememberSaveable { mutableStateOf(false) }
|
||||
val isHoldDown = remember { mutableStateOf(false) }
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val hapticLatest by rememberUpdatedState(haptic)
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val currentHapticFeedback by rememberUpdatedState(hapticFeedback)
|
||||
val currentOnExpandedChange = rememberUpdatedState(onExpandedChange)
|
||||
val setExpanded: (Boolean) -> Unit = remember {
|
||||
{ expanded ->
|
||||
if (isDropdownExpanded.value != expanded) {
|
||||
isDropdownExpanded.value = expanded
|
||||
currentOnExpandedChange.value?.invoke(expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val itemsNotEmpty = items.isNotEmpty()
|
||||
val actualEnabled = enabled && itemsNotEmpty
|
||||
val forceOverrideValue = overrideEndActionValue != null
|
||||
val endActionText = overrideEndActionValue
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: if (showValue && itemsNotEmpty) items[selectedIndex].title.orEmpty() else null
|
||||
|
||||
val actionColor = if (actualEnabled) {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantActions
|
||||
@@ -74,10 +82,10 @@ fun SuperSpinner(
|
||||
val handleClick = remember(actualEnabled) {
|
||||
{
|
||||
if (actualEnabled) {
|
||||
isDropdownExpanded.value = !isDropdownExpanded.value
|
||||
setExpanded(!isDropdownExpanded.value)
|
||||
if (isDropdownExpanded.value) {
|
||||
isHoldDown.value = true
|
||||
hapticLatest.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
currentHapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,9 +101,9 @@ fun SuperSpinner(
|
||||
summaryColor = summaryColor,
|
||||
startAction = startAction,
|
||||
endActions = {
|
||||
if (endActionText != null) {
|
||||
if (showValue && itemsNotEmpty) {
|
||||
Text(
|
||||
text = endActionText,
|
||||
text = items[selectedIndex].title ?: "",
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.align(Alignment.CenterVertically)
|
||||
@@ -105,22 +113,19 @@ fun SuperSpinner(
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
DropdownArrowEndAction(
|
||||
actionColor = actionColor,
|
||||
)
|
||||
DropdownArrowEndAction(actionColor = actionColor)
|
||||
if (itemsNotEmpty) {
|
||||
SuperSpinnerPopup(
|
||||
OverlaySpinnerPopup(
|
||||
items = items,
|
||||
selectedIndex = selectedIndex,
|
||||
isDropdownExpanded = isDropdownExpanded.value,
|
||||
onDismiss = { isDropdownExpanded.value = false },
|
||||
onDismiss = { setExpanded(false) },
|
||||
onDismissFinished = { isHoldDown.value = false },
|
||||
maxHeight = maxHeight,
|
||||
hapticFeedback = haptic,
|
||||
hapticFeedback = hapticFeedback,
|
||||
spinnerColors = spinnerColors,
|
||||
renderInRootScaffold = renderInRootScaffold,
|
||||
onSelectedIndexChange = onSelectedIndexChange,
|
||||
forceNoSelectedState = forceOverrideValue,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -132,7 +137,7 @@ fun SuperSpinner(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuperSpinnerPopup(
|
||||
private fun OverlaySpinnerPopup(
|
||||
items: List<SpinnerEntry>,
|
||||
selectedIndex: Int,
|
||||
isDropdownExpanded: Boolean,
|
||||
@@ -143,14 +148,13 @@ private fun SuperSpinnerPopup(
|
||||
spinnerColors: SpinnerColors,
|
||||
renderInRootScaffold: Boolean,
|
||||
onSelectedIndexChange: ((Int) -> Unit)?,
|
||||
forceNoSelectedState: Boolean,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val onSelectState = rememberUpdatedState(onSelectedIndexChange)
|
||||
val currentOnDismiss by rememberUpdatedState(onDismiss)
|
||||
val currentHapticFeedback by rememberUpdatedState(hapticFeedback)
|
||||
val onItemSelected: (Int) -> Unit = remember {
|
||||
{ selectedIdx ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||
currentHapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||
onSelectState.value?.invoke(selectedIdx)
|
||||
currentOnDismiss()
|
||||
}
|
||||
@@ -169,14 +173,15 @@ private fun SuperSpinnerPopup(
|
||||
SpinnerItemImpl(
|
||||
entry = spinnerEntry,
|
||||
entryCount = items.size,
|
||||
isSelected = !forceNoSelectedState && selectedIndex == index,
|
||||
isSelected = selectedIndex == index,
|
||||
index = index,
|
||||
spinnerColors = spinnerColors,
|
||||
dialogMode = false,
|
||||
enabled = spinnerEntry.enabled,
|
||||
onSelectedIndexChange = onItemSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,6 @@ import top.yukonga.miuix.kmp.blur.BlendColorEntry
|
||||
import top.yukonga.miuix.kmp.blur.BlurBlendMode
|
||||
import top.yukonga.miuix.kmp.blur.BlurColors
|
||||
import top.yukonga.miuix.kmp.blur.BlurDefaults
|
||||
import top.yukonga.miuix.kmp.blur.LayerBackdrop
|
||||
import top.yukonga.miuix.kmp.blur.isRenderEffectSupported
|
||||
import top.yukonga.miuix.kmp.blur.layerBackdrop
|
||||
import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop
|
||||
@@ -197,6 +196,31 @@ private fun AboutContent(
|
||||
.collect {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AboutCard(
|
||||
modifier: Modifier = Modifier.padding(horizontal = 12.dp),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.textureBlur(
|
||||
backdrop = backdrop,
|
||||
shape = SmoothRoundedCornerShape(16.dp),
|
||||
blurRadius = 60f,
|
||||
noiseCoefficient = BlurDefaults.NoiseCoefficient,
|
||||
colors = BlurColors(blendColors = cardBlendColors),
|
||||
enabled = blurEnabled,
|
||||
),
|
||||
colors = CardDefaults.defaultColors(
|
||||
color =
|
||||
if (blurEnabled) Color.Transparent
|
||||
else colorScheme.surfaceContainer,
|
||||
contentColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
BgEffectBackground(
|
||||
dynamicBackground = true,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -348,12 +372,7 @@ private fun AboutContent(
|
||||
.padding(bottom = padding.calculateBottomPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
AboutCard(
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
backdrop = backdrop,
|
||||
blurEnabled = blurEnabled,
|
||||
blendColors = cardBlendColors,
|
||||
) {
|
||||
AboutCard {
|
||||
ArrowPreference(
|
||||
title = "项目仓库",
|
||||
endActions = {
|
||||
@@ -382,17 +401,15 @@ private fun AboutContent(
|
||||
},
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, releasesUrl.toUri())
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
releasesUrl.toUri(),
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
AboutCard(
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
backdrop = backdrop,
|
||||
blurEnabled = blurEnabled,
|
||||
blendColors = cardBlendColors,
|
||||
) {
|
||||
AboutCard {
|
||||
ArrowPreference(
|
||||
title = "License",
|
||||
endActions = {
|
||||
@@ -406,46 +423,43 @@ private fun AboutContent(
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
"https://www.apache.org/licenses/LICENSE-2.0.txt".toUri()
|
||||
"https://www.apache.org/licenses/LICENSE-2.0.txt".toUri(),
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
AboutCard {
|
||||
listOf(
|
||||
Pair("Miuix", "https://github.com/compose-miuix-ui/miuix"),
|
||||
Pair("scrcpy", "https://github.com/Genymobile/scrcpy"),
|
||||
).forEach { (name, repo) ->
|
||||
ArrowPreference(
|
||||
title = name,
|
||||
endActions = {
|
||||
Text(
|
||||
text = "GitHub",
|
||||
fontSize = textStyles.body2.fontSize,
|
||||
color = colorScheme.onSurfaceVariantActions,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
repo.toUri(),
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutCard(
|
||||
modifier: Modifier,
|
||||
backdrop: LayerBackdrop,
|
||||
blurEnabled: Boolean,
|
||||
blendColors: List<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> =
|
||||
if (isDark) listOf(
|
||||
BlendColorEntry(Color(0xE6A1A1A1), BlurBlendMode.ColorDodge),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -56,6 +56,7 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||
@@ -267,21 +268,6 @@ fun FullscreenControlScreen(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendKeycode(keycode: Int) {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.injectKeycode(0, keycode)
|
||||
scrcpy.injectKeycode(1, keycode)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Log.w(
|
||||
"FullscreenControlPage",
|
||||
"sendKeycode failed for keycode=$keycode",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendBackOrTurnScreenOn() {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -295,9 +281,7 @@ fun FullscreenControlScreen(
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
if (asBundle.fullscreenControlBackToDevice && currentSession != null)
|
||||
taskScope.launch {
|
||||
sendBackOrTurnScreenOn()
|
||||
}
|
||||
taskScope.launch { sendBackOrTurnScreenOn() }
|
||||
else onBack()
|
||||
}
|
||||
|
||||
@@ -323,34 +307,6 @@ fun FullscreenControlScreen(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pasteLocalClipboard() {
|
||||
val session = currentSession ?: return
|
||||
val text = io.github.miuzarte.scrcpyforandroid.services.LocalInputService.getClipboardText(
|
||||
activity ?: return
|
||||
)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (text == null) {
|
||||
snackbar.show("本机剪贴板为空或不是文本")
|
||||
return
|
||||
}
|
||||
val useLegacyPaste = session.legacyPaste
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (useLegacyPaste) {
|
||||
scrcpy.injectText(text)
|
||||
} else {
|
||||
scrcpy.setClipboard(text, paste = true)
|
||||
}
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Log.w("FullscreenControlPage", "pasteLocalClipboard failed", error)
|
||||
snackbar.show(
|
||||
if (useLegacyPaste) "legacy 粘贴失败"
|
||||
else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun commitImeText(text: String) {
|
||||
submitImeText(
|
||||
scrcpy = scrcpy,
|
||||
@@ -365,6 +321,78 @@ fun FullscreenControlScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun handleButtonAction(action: VirtualButtonAction) {
|
||||
when (action) {
|
||||
VirtualButtonAction.RECENT_TASKS -> {
|
||||
showRecentTasksSheet = true
|
||||
if (recentTasks.isEmpty() && !listingsRefreshBusy) {
|
||||
taskScope.launch {
|
||||
refreshApps()
|
||||
refreshRecentTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VirtualButtonAction.ALL_APPS -> {
|
||||
showAllAppsSheet = true
|
||||
if (apps.isEmpty() && !listingsRefreshBusy) {
|
||||
taskScope.launch {
|
||||
refreshApps()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VirtualButtonAction.TOGGLE_IME -> imeRequestToken++
|
||||
|
||||
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD ->
|
||||
taskScope.launch {
|
||||
val session = currentSession ?: return@launch
|
||||
val text = LocalInputService.getClipboardText(activity ?: return@launch)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (text == null) {
|
||||
snackbar.show("本机剪贴板为空或不是文本")
|
||||
return@launch
|
||||
}
|
||||
val useLegacyPaste = session.legacyPaste
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (useLegacyPaste) scrcpy.injectText(text)
|
||||
else scrcpy.setClipboard(text, paste = true)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Log.w("FullscreenControl", "pasteLocalClipboard failed", error)
|
||||
snackbar.show(if (useLegacyPaste) "legacy 粘贴失败" else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste")
|
||||
}
|
||||
}
|
||||
|
||||
else -> action.keycode?.let {
|
||||
taskScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.injectKeycode(0, it)
|
||||
scrcpy.injectKeycode(1, it)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Log.w(
|
||||
"FullscreenControlPage",
|
||||
"sendKeycode failed for keycode=$it",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startApp(packageName: String) =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.startApp(packageName)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
snackbarHost = { SnackbarHost(snackbar.hostState) },
|
||||
@@ -402,9 +430,7 @@ fun FullscreenControlScreen(
|
||||
}
|
||||
},
|
||||
onBackOrScreenOn = { action ->
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.pressBackOrTurnScreenOn(action)
|
||||
}
|
||||
withContext(Dispatchers.IO) { scrcpy.pressBackOrTurnScreenOn(action) }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -421,44 +447,9 @@ fun FullscreenControlScreen(
|
||||
dock = fullscreenVirtualButtonDock,
|
||||
reverseOrder = fullscreenVirtualButtonReverseOrder,
|
||||
thickness = fullscreenVirtualButtonHeight,
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
VirtualButtonAction.RECENT_TASKS -> {
|
||||
showRecentTasksSheet = true
|
||||
if (recentTasks.isEmpty() && !listingsRefreshBusy) {
|
||||
refreshApps()
|
||||
refreshRecentTasks()
|
||||
}
|
||||
}
|
||||
|
||||
VirtualButtonAction.ALL_APPS -> {
|
||||
showAllAppsSheet = true
|
||||
if (apps.isEmpty() && !listingsRefreshBusy) {
|
||||
refreshApps()
|
||||
}
|
||||
}
|
||||
|
||||
VirtualButtonAction.TOGGLE_IME -> {
|
||||
imeRequestToken++
|
||||
}
|
||||
|
||||
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> {
|
||||
pasteLocalClipboard()
|
||||
}
|
||||
|
||||
else -> {
|
||||
action.keycode?.let { sendKeycode(it) }
|
||||
}
|
||||
}
|
||||
},
|
||||
passwordPopupContent = if (fragmentActivity == null) {
|
||||
null
|
||||
} else {
|
||||
{ onDismissRequest ->
|
||||
PasswordPickerPopupContent(
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
onAction = ::handleButtonAction,
|
||||
passwordPopupContent = fragmentActivity?.let {
|
||||
{ onDismissRequest -> PasswordPickerPopupContent(onDismissRequest = onDismissRequest) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -467,43 +458,10 @@ fun FullscreenControlScreen(
|
||||
bar.FloatingBall(
|
||||
actions = floatingActions,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
VirtualButtonAction.RECENT_TASKS -> {
|
||||
showRecentTasksSheet = true
|
||||
if (recentTasks.isEmpty() && !listingsRefreshBusy) {
|
||||
refreshApps()
|
||||
refreshRecentTasks()
|
||||
}
|
||||
}
|
||||
|
||||
VirtualButtonAction.ALL_APPS -> {
|
||||
showAllAppsSheet = true
|
||||
if (apps.isEmpty() && !listingsRefreshBusy) {
|
||||
refreshApps()
|
||||
}
|
||||
}
|
||||
|
||||
VirtualButtonAction.TOGGLE_IME -> {
|
||||
imeRequestToken++
|
||||
}
|
||||
|
||||
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> {
|
||||
pasteLocalClipboard()
|
||||
}
|
||||
|
||||
else -> {
|
||||
action.keycode?.let { sendKeycode(it) }
|
||||
}
|
||||
}
|
||||
onAction = ::handleButtonAction,
|
||||
passwordPopupContent = fragmentActivity?.let {
|
||||
{ onDismissRequest -> PasswordPickerPopupContent(onDismissRequest = onDismissRequest) }
|
||||
},
|
||||
passwordPopupContent =
|
||||
if (fragmentActivity == null) null
|
||||
else { onDismissRequest ->
|
||||
PasswordPickerPopupContent(
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -521,15 +479,7 @@ fun FullscreenControlScreen(
|
||||
system = app?.system,
|
||||
onClick = {
|
||||
showRecentTasksSheet = false
|
||||
taskScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.startApp(task.packageName)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
taskScope.launch { startApp(task.packageName) }
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -556,15 +506,7 @@ fun FullscreenControlScreen(
|
||||
system = app.system,
|
||||
onClick = {
|
||||
showAllAppsSheet = false
|
||||
taskScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.startApp(app.packageName)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
taskScope.launch { startApp(app.packageName) }
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -576,7 +518,6 @@ fun FullscreenControlScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,22 +617,6 @@ private fun rotateDockDirection(
|
||||
else -> direction
|
||||
}
|
||||
|
||||
/**
|
||||
* FullscreenControlScreen
|
||||
*
|
||||
* Purpose:
|
||||
* - Presents a fullscreen interactive touch surface that maps Compose touch events
|
||||
* to device coordinates and injects them via [onInjectTouch].
|
||||
* - Responsible for pointer tracking, multi-touch mapping, coordinate normalization,
|
||||
* and lifetime of synthetic touch events sent to the device.
|
||||
*
|
||||
* Concurrency and side-effects:
|
||||
* - All heavy computations are local to the UI thread; injection itself is a quick
|
||||
* callback (`onInjectTouch`) which delegates to native code elsewhere — keep that
|
||||
* callback lightweight.
|
||||
* - Use `pointerInteropFilter` to receive raw MotionEvent instances for precise
|
||||
* multi-touch handling and to map Android pointer IDs to device pointers.
|
||||
*/
|
||||
@Composable
|
||||
fun FullscreenControlPage(
|
||||
scrcpy: Scrcpy,
|
||||
|
||||
@@ -314,6 +314,11 @@ fun MainScreen() {
|
||||
autoReconnectManager = autoReconnectManager,
|
||||
)
|
||||
}
|
||||
|
||||
val deviceTabViewModelFactory = remember(scrcpy, deviceConnectionServices) {
|
||||
DeviceTabViewModel.Factory(scrcpy, deviceConnectionServices)
|
||||
}
|
||||
|
||||
DisposableEffect(deviceConnectionServices) {
|
||||
onDispose {
|
||||
deviceConnectionServices.autoReconnectManager.close()
|
||||
@@ -568,9 +573,8 @@ fun MainScreen() {
|
||||
saveableStateHolder.SaveableStateProvider(tab.name) {
|
||||
when (tab) {
|
||||
MainBottomTabDestination.Devices -> DeviceTabScreen(
|
||||
viewModelFactory = deviceTabViewModelFactory,
|
||||
scrollBehavior = devicesPageScrollBehavior,
|
||||
scrcpy = scrcpy,
|
||||
connectionServices = deviceConnectionServices,
|
||||
bottomInnerPadding = bottomInnerPadding,
|
||||
onOpenReorderDevices = { showReorderDevices = true },
|
||||
onPreviewGestureLockChanged = { locked ->
|
||||
|
||||
@@ -46,13 +46,14 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.miuix.SpinnerEntry
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.OverlaySpinnerWithFallback
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSpinner
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
@@ -92,7 +93,6 @@ import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.SnackbarHost
|
||||
import top.yukonga.miuix.kmp.basic.SpinnerEntry
|
||||
import top.yukonga.miuix.kmp.basic.TabRow
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
@@ -105,7 +105,6 @@ import top.yukonga.miuix.kmp.icon.extended.Store
|
||||
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
|
||||
import top.yukonga.miuix.kmp.overlay.OverlayDialog
|
||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||
import top.yukonga.miuix.kmp.preference.ArrowPreference
|
||||
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
|
||||
import top.yukonga.miuix.kmp.preference.SwitchPreference
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
@@ -257,9 +256,10 @@ internal fun ScrcpyAllOptionsScreen(
|
||||
onDismissRequest = { showProfileMenu = false },
|
||||
) {
|
||||
ListPopupColumn {
|
||||
ProfileMenuPopupItem(
|
||||
DropdownImpl(
|
||||
text = "管理配置",
|
||||
optionSize = 1,
|
||||
isSelected = false,
|
||||
index = 0,
|
||||
onSelectedIndexChange = {
|
||||
showManageProfilesSheet = true
|
||||
@@ -516,9 +516,7 @@ internal fun ScrcpyAllOptionsPage(
|
||||
(displays.indexOfFirst { it.id == soBundle.displayId } + 1).coerceAtLeast(0)
|
||||
}
|
||||
val displayOverrideEndActionValue = remember(
|
||||
displays,
|
||||
listRefreshVersion,
|
||||
soBundle.displayId,
|
||||
displays, listRefreshVersion, soBundle.displayId,
|
||||
) {
|
||||
if (displays.isEmpty() && soBundle.displayId >= 0) soBundle.displayId.toString()
|
||||
else null
|
||||
@@ -550,9 +548,7 @@ internal fun ScrcpyAllOptionsPage(
|
||||
(cameras.indexOfFirst { it.id == soBundle.cameraId } + 1).coerceAtLeast(0)
|
||||
}
|
||||
val cameraOverrideEndActionValue = remember(
|
||||
cameras,
|
||||
listRefreshVersion,
|
||||
soBundle.cameraId,
|
||||
cameras, listRefreshVersion, soBundle.cameraId,
|
||||
) {
|
||||
if (cameras.isEmpty() && soBundle.cameraId.isNotBlank()) soBundle.cameraId
|
||||
else null
|
||||
@@ -601,11 +597,8 @@ internal fun ScrcpyAllOptionsPage(
|
||||
}
|
||||
}
|
||||
val cameraSizeOverrideEndActionValue = remember(
|
||||
cameraSizes,
|
||||
listRefreshVersion,
|
||||
soBundle.cameraSize,
|
||||
soBundle.cameraSizeCustom,
|
||||
soBundle.cameraSizeUseCustom,
|
||||
cameraSizes, listRefreshVersion,
|
||||
soBundle.cameraSize, soBundle.cameraSizeCustom, soBundle.cameraSizeUseCustom,
|
||||
) {
|
||||
when {
|
||||
cameraSizes.isNotEmpty() -> null
|
||||
@@ -689,9 +682,7 @@ internal fun ScrcpyAllOptionsPage(
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
val videoEncoderOverrideEndActionValue = remember(
|
||||
videoEncoders,
|
||||
listRefreshVersion,
|
||||
soBundle.videoEncoder,
|
||||
videoEncoders, listRefreshVersion, soBundle.videoEncoder,
|
||||
) {
|
||||
if (videoEncoders.isEmpty() && soBundle.videoEncoder.isNotBlank()) soBundle.videoEncoder
|
||||
else null
|
||||
@@ -722,9 +713,7 @@ internal fun ScrcpyAllOptionsPage(
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
val audioEncoderOverrideEndActionValue = remember(
|
||||
audioEncoders,
|
||||
listRefreshVersion,
|
||||
soBundle.audioEncoder,
|
||||
audioEncoders, listRefreshVersion, soBundle.audioEncoder,
|
||||
) {
|
||||
if (audioEncoders.isEmpty() && soBundle.audioEncoder.isNotBlank()) soBundle.audioEncoder
|
||||
else null
|
||||
@@ -792,13 +781,12 @@ internal fun ScrcpyAllOptionsPage(
|
||||
}
|
||||
}
|
||||
val startAppOverrideEndActionValue = remember(
|
||||
apps,
|
||||
listRefreshVersion,
|
||||
soBundle.startApp,
|
||||
apps, listRefreshVersion, soBundle.startApp,
|
||||
) {
|
||||
if (apps.isEmpty() && soBundle.startApp.isNotBlank()) soBundle.startApp
|
||||
else null
|
||||
}
|
||||
|
||||
var startAppCustomInput by rememberSaveable(soBundle.startAppCustom) {
|
||||
mutableStateOf(soBundle.startAppCustom)
|
||||
}
|
||||
@@ -1204,30 +1192,29 @@ internal fun ScrcpyAllOptionsPage(
|
||||
)
|
||||
AnimatedVisibility(soBundle.videoSource == "display") {
|
||||
Column {
|
||||
ArrowPreference(
|
||||
title = "获取 Displays",
|
||||
summary = "--list-displays",
|
||||
onClick = {
|
||||
if (refreshBusy) return@ArrowPreference
|
||||
scope.launch {
|
||||
snackbar.show("获取中")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.listings.getDisplays(forceRefresh = true)
|
||||
}
|
||||
snackbar.show("获取成功")
|
||||
} catch (e: Exception) {
|
||||
snackbar.show("刷新失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
SuperSpinner(
|
||||
OverlaySpinnerWithFallback(
|
||||
title = "监视器 ID",
|
||||
summary = "--display-id",
|
||||
items = displaySpinnerItems,
|
||||
selectedIndex = displayDropdownIndex,
|
||||
dataLoaded = displays.isNotEmpty(),
|
||||
dataLoading = refreshBusy,
|
||||
overrideEndActionValue = displayOverrideEndActionValue,
|
||||
onExpandedChange = { expanded ->
|
||||
if (expanded && displays.isEmpty()) {
|
||||
scope.launch {
|
||||
snackbar.show("获取中")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.listings.getDisplays(forceRefresh = false)
|
||||
}
|
||||
snackbar.show("获取成功")
|
||||
} catch (e: Exception) {
|
||||
snackbar.show("刷新失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectedIndexChange = {
|
||||
soBundle = soBundle.copy(
|
||||
displayId =
|
||||
@@ -1301,30 +1288,29 @@ internal fun ScrcpyAllOptionsPage(
|
||||
}
|
||||
AnimatedVisibility(soBundle.videoSource == "camera") {
|
||||
Column {
|
||||
ArrowPreference(
|
||||
title = "获取 Cameras",
|
||||
summary = "--list-cameras",
|
||||
onClick = {
|
||||
if (refreshBusy) return@ArrowPreference
|
||||
scope.launch {
|
||||
snackbar.show("获取中")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.listings.getCameras(forceRefresh = true)
|
||||
}
|
||||
snackbar.show("获取成功")
|
||||
} catch (e: Exception) {
|
||||
snackbar.show("刷新失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
SuperSpinner(
|
||||
OverlaySpinnerWithFallback(
|
||||
title = "摄像头 ID",
|
||||
summary = "--camera-id",
|
||||
items = cameraSpinnerItems,
|
||||
selectedIndex = cameraDropdownIndex,
|
||||
dataLoaded = cameras.isNotEmpty(),
|
||||
dataLoading = refreshBusy,
|
||||
overrideEndActionValue = cameraOverrideEndActionValue,
|
||||
onExpandedChange = { expanded ->
|
||||
if (expanded && cameras.isEmpty()) {
|
||||
scope.launch {
|
||||
snackbar.show("获取中")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.listings.getCameras(forceRefresh = false)
|
||||
}
|
||||
snackbar.show("获取成功")
|
||||
} catch (e: Exception) {
|
||||
snackbar.show("刷新失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectedIndexChange = {
|
||||
soBundle = soBundle.copy(
|
||||
cameraId =
|
||||
@@ -1346,30 +1332,29 @@ internal fun ScrcpyAllOptionsPage(
|
||||
)
|
||||
},
|
||||
)
|
||||
ArrowPreference(
|
||||
title = "获取 Camera Sizes",
|
||||
summary = "--list-camera-sizes",
|
||||
onClick = {
|
||||
if (refreshBusy) return@ArrowPreference
|
||||
scope.launch {
|
||||
snackbar.show("获取中")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.listings.getCameraSizes(forceRefresh = true)
|
||||
}
|
||||
snackbar.show("获取成功")
|
||||
} catch (e: Exception) {
|
||||
snackbar.show("刷新失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
SuperSpinner(
|
||||
OverlaySpinnerWithFallback(
|
||||
title = "摄像头分辨率",
|
||||
summary = "--camera-size",
|
||||
items = cameraSizeSpinnerItems,
|
||||
selectedIndex = cameraSizeDropdownIndex,
|
||||
dataLoaded = cameraSizes.isNotEmpty(),
|
||||
dataLoading = refreshBusy,
|
||||
overrideEndActionValue = cameraSizeOverrideEndActionValue,
|
||||
onExpandedChange = { expanded ->
|
||||
if (expanded && cameraSizes.isEmpty()) {
|
||||
scope.launch {
|
||||
snackbar.show("获取中")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.listings.getCameraSizes(forceRefresh = false)
|
||||
}
|
||||
snackbar.show("获取成功")
|
||||
} catch (e: Exception) {
|
||||
snackbar.show("刷新失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectedIndexChange = {
|
||||
when (it) {
|
||||
0 -> {
|
||||
@@ -1524,31 +1509,29 @@ internal fun ScrcpyAllOptionsPage(
|
||||
|
||||
item {
|
||||
Card {
|
||||
ArrowPreference(
|
||||
title = "获取编码器列表",
|
||||
summary = "--list-encoders",
|
||||
onClick = {
|
||||
if (refreshBusy) return@ArrowPreference
|
||||
scope.launch {
|
||||
snackbar.show("获取中")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.listings.getEncoders(forceRefresh = true)
|
||||
}
|
||||
snackbar.show("获取成功")
|
||||
} catch (e: Exception) {
|
||||
snackbar.show("刷新失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
// TODO: 等 MIUIX 发版, 在 OverlaySpinnerPreference / OverlayDropdownPreference 支持展开状态回调后, 在展开时触发获取
|
||||
SuperSpinner(
|
||||
OverlaySpinnerWithFallback(
|
||||
title = "视频编码器",
|
||||
summary = "--video-encoder",
|
||||
items = videoEncoderItems,
|
||||
selectedIndex = videoEncoderIndex,
|
||||
dataLoaded = videoEncoders.isNotEmpty(),
|
||||
dataLoading = refreshBusy,
|
||||
overrideEndActionValue = videoEncoderOverrideEndActionValue,
|
||||
onExpandedChange = { expanded ->
|
||||
if (expanded && videoEncoders.isEmpty()) {
|
||||
scope.launch {
|
||||
snackbar.show("获取中")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.listings.getEncoders(forceRefresh = false)
|
||||
}
|
||||
snackbar.show("获取成功")
|
||||
} catch (e: Exception) {
|
||||
snackbar.show("刷新失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectedIndexChange = {
|
||||
soBundle = soBundle.copy(
|
||||
videoEncoder =
|
||||
@@ -1571,12 +1554,29 @@ internal fun ScrcpyAllOptionsPage(
|
||||
.fillMaxWidth()
|
||||
.padding(all = UiSpacing.Large),
|
||||
)
|
||||
SuperSpinner(
|
||||
OverlaySpinnerWithFallback(
|
||||
title = "音频编码器",
|
||||
summary = "--audio-encoder",
|
||||
items = audioEncoderItems,
|
||||
selectedIndex = audioEncoderIndex,
|
||||
dataLoaded = audioEncoders.isNotEmpty(),
|
||||
dataLoading = refreshBusy,
|
||||
overrideEndActionValue = audioEncoderOverrideEndActionValue,
|
||||
onExpandedChange = { expanded ->
|
||||
if (expanded && audioEncoders.isEmpty()) {
|
||||
scope.launch {
|
||||
snackbar.show("获取中")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.listings.getEncoders(forceRefresh = false)
|
||||
}
|
||||
snackbar.show("获取成功")
|
||||
} catch (e: Exception) {
|
||||
snackbar.show("刷新失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectedIndexChange = {
|
||||
soBundle = soBundle.copy(
|
||||
audioEncoder =
|
||||
@@ -1734,30 +1734,29 @@ internal fun ScrcpyAllOptionsPage(
|
||||
|
||||
if (soBundle.videoSource == "display") item {
|
||||
Card {
|
||||
ArrowPreference(
|
||||
title = "获取应用列表",
|
||||
summary = "--list-apps",
|
||||
onClick = {
|
||||
if (refreshBusy) return@ArrowPreference
|
||||
scope.launch {
|
||||
snackbar.show("获取中")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.listings.getApps(forceRefresh = true)
|
||||
}
|
||||
snackbar.show("获取成功")
|
||||
} catch (e: Exception) {
|
||||
snackbar.show("刷新失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
SuperSpinner(
|
||||
OverlaySpinnerWithFallback(
|
||||
title = "scrcpy 启动后打开应用",
|
||||
summary = "--start-app",
|
||||
items = appDropdownItems,
|
||||
selectedIndex = appDropdownIndex,
|
||||
dataLoaded = apps.isNotEmpty(),
|
||||
dataLoading = refreshBusy,
|
||||
overrideEndActionValue = startAppOverrideEndActionValue,
|
||||
onExpandedChange = { expanded ->
|
||||
if (expanded && apps.isEmpty()) {
|
||||
scope.launch {
|
||||
snackbar.show("获取中")
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
scrcpy.listings.getApps(forceRefresh = false)
|
||||
}
|
||||
snackbar.show("获取成功")
|
||||
} catch (e: Exception) {
|
||||
snackbar.show("刷新失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectedIndexChange = {
|
||||
when (it) {
|
||||
0 -> {
|
||||
@@ -2112,39 +2111,6 @@ private enum class ProfileDialogMode {
|
||||
Rename,
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileMenuPopupItem(
|
||||
text: String,
|
||||
optionSize: Int,
|
||||
index: Int,
|
||||
enabled: Boolean = true,
|
||||
onSelectedIndexChange: (Int) -> Unit,
|
||||
) {
|
||||
if (enabled) {
|
||||
DropdownImpl(
|
||||
text = text,
|
||||
optionSize = optionSize,
|
||||
isSelected = false,
|
||||
index = index,
|
||||
onSelectedIndexChange = onSelectedIndexChange,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = UiSpacing.PopupHorizontal)
|
||||
.padding(
|
||||
top = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem,
|
||||
bottom = if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem,
|
||||
),
|
||||
color = colorScheme.disabledOnSecondaryVariant,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileNameDialog(
|
||||
mode: ProfileDialogMode?,
|
||||
|
||||
@@ -51,8 +51,6 @@ import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.MonetKeyColorOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdownGroup
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdownPreference
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -60,6 +58,8 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.DropdownEntry
|
||||
import top.yukonga.miuix.kmp.basic.DropdownItem
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
@@ -481,12 +481,11 @@ fun SettingsPage(
|
||||
)
|
||||
AnimatedVisibility(asBundle.showFullscreenVirtualButtons) {
|
||||
Column {
|
||||
MultiGroupsDropdownPreference(
|
||||
title = "虚拟按钮方向",
|
||||
summary = fullscreenVirtualButtonDock.summary,
|
||||
groups = listOf(
|
||||
MultiGroupsDropdownGroup(
|
||||
options = FullscreenVirtualButtonDock.modeItems,
|
||||
OverlayDropdownPreference(
|
||||
entries = listOf(
|
||||
DropdownEntry(
|
||||
items = FullscreenVirtualButtonDock
|
||||
.modeItems.map { DropdownItem(it) },
|
||||
selectedIndex = fullscreenVirtualButtonDock.modeIndex,
|
||||
onSelectedIndexChange = { modeIndex ->
|
||||
asBundle = asBundle.copy(
|
||||
@@ -499,8 +498,9 @@ fun SettingsPage(
|
||||
)
|
||||
},
|
||||
),
|
||||
MultiGroupsDropdownGroup(
|
||||
options = FullscreenVirtualButtonDock.directionItems,
|
||||
DropdownEntry(
|
||||
items = FullscreenVirtualButtonDock
|
||||
.directionItems.map { DropdownItem(it) },
|
||||
selectedIndex = fullscreenVirtualButtonDock.directionIndex,
|
||||
onSelectedIndexChange = { directionIndex ->
|
||||
asBundle = asBundle.copy(
|
||||
@@ -514,6 +514,8 @@ fun SettingsPage(
|
||||
},
|
||||
),
|
||||
),
|
||||
title = "虚拟按钮方向",
|
||||
summary = fullscreenVirtualButtonDock.summary,
|
||||
)
|
||||
SuperSlider(
|
||||
title = "虚拟按钮高度",
|
||||
|
||||
@@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -44,38 +43,24 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.termux.terminal.KeyHandler
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.termux.terminal.TerminalSession
|
||||
import com.termux.terminal.TextStyle
|
||||
import com.termux.view.TerminalView
|
||||
import com.termux.view.TerminalViewClient
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
|
||||
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.PopupMenuItem
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yukonga.miuix.kmp.basic.DropdownImpl
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||
import top.yukonga.miuix.kmp.basic.SnackbarDuration
|
||||
import top.yukonga.miuix.kmp.basic.SnackbarResult
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.blur.layerBackdrop
|
||||
@@ -85,21 +70,14 @@ import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
|
||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val DEFAULT_TERMINAL_FONT_SIZE_SP = 14f
|
||||
private const val MIN_TERMINAL_FONT_SIZE_SP = 1f
|
||||
private const val MAX_TERMINAL_FONT_SIZE_SP = 32f
|
||||
private const val FONT_SCALE_STEP_THRESHOLD = 1.08f
|
||||
private const val LOG_TAG = "TerminalScreen"
|
||||
private const val TERMINAL_FONT_RELATIVE_PATH = "terminal/font.ttf"
|
||||
|
||||
private fun terminalFontFile(context: android.content.Context): File {
|
||||
return File(context.filesDir, TERMINAL_FONT_RELATIVE_PATH)
|
||||
}
|
||||
private fun terminalFontFile(context: android.content.Context) =
|
||||
File(context.filesDir, "terminal/font.ttf")
|
||||
|
||||
private fun loadTerminalTypeface(context: android.content.Context): Typeface? {
|
||||
val file = terminalFontFile(context)
|
||||
@@ -113,6 +91,7 @@ fun TerminalScreen(
|
||||
isActive: Boolean,
|
||||
onTerminalGestureLockChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
val viewModel: TerminalViewModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
val snackbar = LocalSnackbarController.current
|
||||
val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current)
|
||||
@@ -121,6 +100,10 @@ fun TerminalScreen(
|
||||
var showOutputSheet by rememberSaveable { mutableStateOf(false) }
|
||||
var output by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.snackbarEvents.collect { snackbar.show(it) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
BlurredBar(backdrop = blurBackdrop) {
|
||||
@@ -144,9 +127,10 @@ fun TerminalScreen(
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
ListPopupColumn {
|
||||
PopupMenuItem(
|
||||
DropdownImpl(
|
||||
text = "自由复制",
|
||||
optionSize = 2,
|
||||
isSelected = false,
|
||||
index = 0,
|
||||
enabled = output.isNotBlank(),
|
||||
onSelectedIndexChange = {
|
||||
@@ -154,9 +138,10 @@ fun TerminalScreen(
|
||||
showOutputSheet = true
|
||||
},
|
||||
)
|
||||
PopupMenuItem(
|
||||
DropdownImpl(
|
||||
text = "清屏",
|
||||
optionSize = 2,
|
||||
isSelected = false,
|
||||
index = 1,
|
||||
onSelectedIndexChange = {
|
||||
showMenu = false
|
||||
@@ -174,9 +159,10 @@ fun TerminalScreen(
|
||||
Box(
|
||||
modifier =
|
||||
if (blurActive) Modifier.layerBackdrop(blurBackdrop)
|
||||
else Modifier,
|
||||
else Modifier
|
||||
) {
|
||||
TerminalPage(
|
||||
viewModel = viewModel,
|
||||
contentPadding = pagePadding,
|
||||
bottomInnerPadding = bottomInnerPadding,
|
||||
isActive = isActive,
|
||||
@@ -184,7 +170,6 @@ fun TerminalScreen(
|
||||
output = output,
|
||||
onOutputChange = { output = it },
|
||||
)
|
||||
|
||||
TerminalOutputBottomSheet(
|
||||
show = showOutputSheet,
|
||||
output = output,
|
||||
@@ -202,6 +187,7 @@ fun TerminalScreen(
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@Composable
|
||||
private fun TerminalPage(
|
||||
viewModel: TerminalViewModel,
|
||||
contentPadding: PaddingValues,
|
||||
bottomInnerPadding: Dp,
|
||||
isActive: Boolean,
|
||||
@@ -213,151 +199,81 @@ private fun TerminalPage(
|
||||
val density = LocalDensity.current
|
||||
val snackbar = LocalSnackbarController.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||
val uiScope = rememberCoroutineScope()
|
||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
val shellWriteMutex = remember { Mutex() }
|
||||
var terminalFontSizeSp by rememberSaveable(asBundleShared.terminalFontSizeSp) {
|
||||
mutableFloatStateOf(asBundleShared.terminalFontSizeSp)
|
||||
}
|
||||
var shellReady by remember { mutableStateOf(false) }
|
||||
var shellConnecting by remember { mutableStateOf(false) }
|
||||
var shellStream by remember { mutableStateOf<AdbSocketStream?>(null) }
|
||||
var terminalView by remember { mutableStateOf<TerminalView?>(null) }
|
||||
|
||||
val asBundle by viewModel.asBundle.collectAsState()
|
||||
val terminalFontSizeSp by viewModel.terminalFontSizeSp.collectAsState()
|
||||
|
||||
val imeBottomDp = with(density) { WindowInsets.ime.getBottom(this).toDp() }
|
||||
var ctrlLatched by rememberSaveable { mutableStateOf(false) }
|
||||
var altLatched by rememberSaveable { mutableStateOf(false) }
|
||||
var pendingLatchedConsume by remember { mutableStateOf(false) }
|
||||
var pinchGestureLock by remember { mutableStateOf(false) }
|
||||
var terminalTouchStartX by remember { mutableFloatStateOf(0f) }
|
||||
var terminalTouchStartY by remember { mutableFloatStateOf(0f) }
|
||||
val terminalTouchSlop = remember(context) {
|
||||
ViewConfiguration.get(context).scaledTouchSlop
|
||||
}
|
||||
val terminalTouchSlop = remember(context) { ViewConfiguration.get(context).scaledTouchSlop }
|
||||
var customTypeface by remember { mutableStateOf(loadTerminalTypeface(context)) }
|
||||
val sessionHolder = remember { arrayOfNulls<TerminalSession>(1) }
|
||||
var terminalView by remember { mutableStateOf<TerminalView?>(null) }
|
||||
|
||||
val terminalSurfaceColorArgb = colorScheme.surface.toArgb()
|
||||
val terminalOnSurfaceColorArgb = colorScheme.onSurface.toArgb()
|
||||
val terminalCursorColorArgb =
|
||||
if (colorScheme.surface.luminance() < 0.5f) 0xffffffff.toInt()
|
||||
else 0xff000000.toInt()
|
||||
|
||||
LaunchedEffect(asBundleShared.terminalFontSizeSp) {
|
||||
if (terminalFontSizeSp != asBundleShared.terminalFontSizeSp) {
|
||||
terminalFontSizeSp = asBundleShared.terminalFontSizeSp
|
||||
}
|
||||
}
|
||||
|
||||
fun extractTranscript(session: TerminalSession): String {
|
||||
val screen = session.emulator.getScreen()
|
||||
return screen.getSelectedText(
|
||||
selX1 = 0,
|
||||
selY1 = -screen.activeTranscriptRows,
|
||||
selX2 = session.emulator.mColumns,
|
||||
selY2 = session.emulator.mRows - 1,
|
||||
joinBackLines = false,
|
||||
joinFullLines = false,
|
||||
).trim('\n')
|
||||
}
|
||||
|
||||
fun syncOutput() {
|
||||
val session = sessionHolder[0] ?: return
|
||||
onOutputChange(extractTranscript(session))
|
||||
terminalView?.onScreenUpdated()
|
||||
}
|
||||
|
||||
fun writeBytesToShell(
|
||||
data: ByteArray,
|
||||
offset: Int,
|
||||
count: Int,
|
||||
) {
|
||||
val stream = shellStream
|
||||
if (stream == null || !shellReady || stream.closed) {
|
||||
return
|
||||
}
|
||||
val payload = data.copyOfRange(offset, offset + count)
|
||||
taskScope.launch {
|
||||
val result = runCatching {
|
||||
shellWriteMutex.withLock {
|
||||
stream.outputStream.write(payload)
|
||||
stream.outputStream.flush()
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
result.onFailure { error ->
|
||||
snackbar.show("终端输入失败: ${error.message ?: error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun writeClipboardToShell() {
|
||||
val text = LocalInputService.getClipboardText(context)
|
||||
if (!text.isNullOrBlank()) {
|
||||
val bytes = text.toByteArray(StandardCharsets.UTF_8)
|
||||
writeBytesToShell(bytes, 0, bytes.size)
|
||||
}
|
||||
}
|
||||
|
||||
val terminalSession = remember {
|
||||
TerminalSession(
|
||||
shellWriter = ::writeBytesToShell,
|
||||
onScreenUpdated = ::syncOutput,
|
||||
shellWriter = { data, offset, count ->
|
||||
viewModel.writeBytesToShell(data, offset, count)
|
||||
},
|
||||
onScreenUpdated = {
|
||||
viewModel.syncOutput { onOutputChange(it) }
|
||||
terminalView?.onScreenUpdated()
|
||||
},
|
||||
onCopyTextToClipboardRequested = { text ->
|
||||
LocalInputService.setClipboardText(context, text)
|
||||
uiScope.launch {
|
||||
snackbar.show("已复制到剪贴板")
|
||||
}
|
||||
snackbar.show("已复制到剪贴板")
|
||||
},
|
||||
onPasteTextFromClipboardRequested = ::writeClipboardToShell,
|
||||
onPasteTextFromClipboardRequested = { viewModel.writeClipboardToShell(context) },
|
||||
onBellRequested = {},
|
||||
)
|
||||
}
|
||||
sessionHolder[0] = terminalSession
|
||||
|
||||
fun applyTerminalThemeColors() {
|
||||
val colors = terminalSession.emulator.mColors.mCurrentColors
|
||||
colors[TextStyle.COLOR_INDEX_BACKGROUND] = terminalSurfaceColorArgb
|
||||
colors[TextStyle.COLOR_INDEX_FOREGROUND] = terminalOnSurfaceColorArgb
|
||||
colors[TextStyle.COLOR_INDEX_CURSOR] = terminalCursorColorArgb
|
||||
).also { viewModel.sessionHolder[0] = it }
|
||||
}
|
||||
|
||||
fun updateTerminalFontSize(newValue: Float) {
|
||||
val clamped = newValue.coerceIn(MIN_TERMINAL_FONT_SIZE_SP, MAX_TERMINAL_FONT_SIZE_SP)
|
||||
if (clamped == terminalFontSizeSp) {
|
||||
return
|
||||
fun requestTerminalFocus() {
|
||||
terminalView?.requestFocusFromTouch()
|
||||
terminalView?.requestFocus()
|
||||
terminalView?.post {
|
||||
terminalView?.requestFocusFromTouch()
|
||||
terminalView?.requestFocus()
|
||||
terminalView?.let(LocalInputService::showSoftKeyboard)
|
||||
}
|
||||
terminalFontSizeSp = clamped
|
||||
terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
|
||||
uiScope.launch {
|
||||
val latest = appSettings.bundleState.value
|
||||
if (latest.terminalFontSizeSp != clamped) {
|
||||
appSettings.updateBundle {
|
||||
it.copy(terminalFontSizeSp = clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openShellSession(showKeyboardAfterConnect: Boolean) {
|
||||
viewModel.openShellSession(showKeyboardAfterConnect, ::requestTerminalFocus)
|
||||
}
|
||||
|
||||
fun updateFontSize(newValue: Float) {
|
||||
viewModel.updateTerminalFontSize(newValue) { clamped ->
|
||||
terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
|
||||
}
|
||||
}
|
||||
|
||||
fun showFontSizeSnackbar() {
|
||||
uiScope.launch {
|
||||
snackbar.hostState.newestSnackbarData()?.dismiss()
|
||||
val result = snackbar.hostState.showSnackbar(
|
||||
message = "终端字号 ${terminalFontSizeSp.roundToInt()}sp",
|
||||
actionLabel = "恢复默认",
|
||||
withDismissAction = true,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
updateTerminalFontSize(DEFAULT_TERMINAL_FONT_SIZE_SP)
|
||||
}
|
||||
}
|
||||
viewModel.launchFontSizeSnackbar(
|
||||
fontSizeSp = terminalFontSizeSp,
|
||||
hostState = snackbar.hostState,
|
||||
onReset = { clamped ->
|
||||
terminalView?.setTextSize(with(density) { clamped.sp.roundToPx() })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun handleTerminalTouchInterception(
|
||||
view: TerminalView,
|
||||
event: MotionEvent,
|
||||
): Boolean {
|
||||
fun applyTheme() {
|
||||
viewModel.applyTerminalThemeColors(
|
||||
terminalSurfaceColorArgb,
|
||||
terminalOnSurfaceColorArgb,
|
||||
terminalCursorColorArgb,
|
||||
)
|
||||
}
|
||||
|
||||
fun handleTerminalTouchInterception(view: TerminalView, event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
terminalTouchStartX = event.x
|
||||
@@ -384,7 +300,6 @@ private fun TerminalPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val shouldUnlock = event.actionMasked == MotionEvent.ACTION_UP ||
|
||||
event.actionMasked == MotionEvent.ACTION_CANCEL ||
|
||||
(event.actionMasked == MotionEvent.ACTION_POINTER_UP && event.pointerCount <= 2)
|
||||
@@ -398,143 +313,18 @@ private fun TerminalPage(
|
||||
return false
|
||||
}
|
||||
|
||||
fun openShellSession(showKeyboardAfterConnect: Boolean) {
|
||||
if (shellStream != null || shellConnecting) {
|
||||
if (shellReady && showKeyboardAfterConnect) {
|
||||
terminalView?.requestFocusFromTouch()
|
||||
terminalView?.requestFocus()
|
||||
terminalView?.post {
|
||||
terminalView?.requestFocusFromTouch()
|
||||
terminalView?.requestFocus()
|
||||
terminalView?.let(LocalInputService::showSoftKeyboard)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
shellConnecting = true
|
||||
taskScope.launch {
|
||||
val streamResult = runCatching {
|
||||
NativeAdbService.openShellStream("")
|
||||
}
|
||||
val stream = streamResult.getOrElse { error ->
|
||||
withContext(Dispatchers.Main) {
|
||||
shellConnecting = false
|
||||
shellReady = false
|
||||
snackbar.show("终端会话创建失败: ${error.message ?: error.javaClass.simpleName}")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
shellStream = stream
|
||||
shellReady = true
|
||||
shellConnecting = false
|
||||
if (showKeyboardAfterConnect) {
|
||||
terminalView?.requestFocusFromTouch()
|
||||
terminalView?.requestFocus()
|
||||
terminalView?.post {
|
||||
terminalView?.requestFocusFromTouch()
|
||||
terminalView?.requestFocus()
|
||||
terminalView?.let(LocalInputService::showSoftKeyboard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val buffer = ByteArray(4096)
|
||||
try {
|
||||
while (!stream.closed) {
|
||||
val count = stream.inputStream.read(buffer)
|
||||
if (count <= 0) {
|
||||
break
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
terminalSession.append(buffer, count)
|
||||
}
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
snackbar.show("终端输出失败: ${error.message ?: error.javaClass.simpleName}")
|
||||
}
|
||||
} finally {
|
||||
runCatching { stream.close() }
|
||||
withContext(Dispatchers.Main) {
|
||||
if (shellStream === stream) {
|
||||
shellStream = null
|
||||
}
|
||||
shellReady = false
|
||||
shellConnecting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeLatchedModifiers(): Int {
|
||||
var modifiers = 0
|
||||
if (ctrlLatched) modifiers = modifiers or KeyHandler.KEYMOD_CTRL
|
||||
if (altLatched) modifiers = modifiers or KeyHandler.KEYMOD_ALT
|
||||
ctrlLatched = false
|
||||
altLatched = false
|
||||
pendingLatchedConsume = false
|
||||
return modifiers
|
||||
}
|
||||
|
||||
fun consumeLatchedVisualState() {
|
||||
ctrlLatched = false
|
||||
altLatched = false
|
||||
pendingLatchedConsume = false
|
||||
}
|
||||
|
||||
fun applyCtrlModifier(ch: Char): Char {
|
||||
return when (ch) {
|
||||
in 'a'..'z' -> (ch.code - 'a'.code + 1).toChar()
|
||||
in 'A'..'Z' -> (ch.code - 'A'.code + 1).toChar()
|
||||
' ' -> 0.toChar()
|
||||
'[' -> 27.toChar()
|
||||
'\\' -> 28.toChar()
|
||||
']' -> 29.toChar()
|
||||
'^' -> 30.toChar()
|
||||
'_', '/' -> 31.toChar()
|
||||
else -> ch
|
||||
}
|
||||
}
|
||||
|
||||
fun writeLiteralKey(text: String) {
|
||||
var payload = text
|
||||
val modifiers = consumeLatchedModifiers()
|
||||
if ((modifiers and KeyHandler.KEYMOD_CTRL) != 0) {
|
||||
payload = payload.map(::applyCtrlModifier).joinToString(separator = "")
|
||||
}
|
||||
if ((modifiers and KeyHandler.KEYMOD_ALT) != 0) {
|
||||
payload = "\u001B$payload"
|
||||
}
|
||||
val bytes = payload.toByteArray(StandardCharsets.UTF_8)
|
||||
writeBytesToShell(bytes, 0, bytes.size)
|
||||
}
|
||||
|
||||
fun writeSpecialKey(keyCode: Int) {
|
||||
val modifiers = consumeLatchedModifiers()
|
||||
val sequence = KeyHandler.getCode(
|
||||
keyCode,
|
||||
modifiers,
|
||||
terminalSession.emulator.isCursorKeysApplicationMode(),
|
||||
terminalSession.emulator.isKeypadApplicationMode(),
|
||||
) ?: return
|
||||
val bytes = sequence.toByteArray(StandardCharsets.UTF_8)
|
||||
writeBytesToShell(bytes, 0, bytes.size)
|
||||
}
|
||||
|
||||
val terminalViewClient = remember {
|
||||
object : TerminalViewClient {
|
||||
override fun onScale(scale: Float): Float {
|
||||
when {
|
||||
scale >= FONT_SCALE_STEP_THRESHOLD -> {
|
||||
updateTerminalFontSize(terminalFontSizeSp + 1f)
|
||||
updateFontSize(terminalFontSizeSp + 1f)
|
||||
showFontSizeSnackbar()
|
||||
return 1f
|
||||
}
|
||||
|
||||
scale <= 1f / FONT_SCALE_STEP_THRESHOLD -> {
|
||||
updateTerminalFontSize(terminalFontSizeSp - 1f)
|
||||
updateFontSize(terminalFontSizeSp - 1f)
|
||||
showFontSizeSnackbar()
|
||||
return 1f
|
||||
}
|
||||
@@ -543,55 +333,42 @@ private fun TerminalPage(
|
||||
}
|
||||
|
||||
override fun onSingleTapUp(e: MotionEvent) {
|
||||
openShellSession(showKeyboardAfterConnect = true)
|
||||
openShellSession(true)
|
||||
}
|
||||
|
||||
override fun shouldBackButtonBeMappedToEscape(): Boolean = false
|
||||
|
||||
override fun shouldEnforceCharBasedInput(): Boolean = false
|
||||
|
||||
override fun shouldUseCtrlSpaceWorkaround(): Boolean = false
|
||||
|
||||
override fun isTerminalViewSelected(): Boolean = true
|
||||
|
||||
override fun shouldBackButtonBeMappedToEscape() = false
|
||||
override fun shouldEnforceCharBasedInput() = false
|
||||
override fun shouldUseCtrlSpaceWorkaround() = false
|
||||
override fun isTerminalViewSelected() = true
|
||||
override fun copyModeChanged(copyMode: Boolean) = Unit
|
||||
|
||||
override fun onKeyDown(
|
||||
keyCode: Int,
|
||||
e: KeyEvent,
|
||||
session: TerminalSession,
|
||||
): Boolean {
|
||||
if (e.action == KeyEvent.ACTION_DOWN && (ctrlLatched || altLatched)) {
|
||||
pendingLatchedConsume = true
|
||||
}
|
||||
if (e.action == KeyEvent.ACTION_DOWN && (viewModel.ctrlLatched || viewModel.altLatched))
|
||||
viewModel.pendingLatchedConsume = true
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, e: KeyEvent): Boolean {
|
||||
if (e.action == KeyEvent.ACTION_UP && pendingLatchedConsume) {
|
||||
consumeLatchedVisualState()
|
||||
}
|
||||
if (e.action == KeyEvent.ACTION_UP && viewModel.pendingLatchedConsume)
|
||||
viewModel.consumeLatchedVisualState()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onLongPress(event: MotionEvent): Boolean = false
|
||||
|
||||
override fun readControlKey(): Boolean = ctrlLatched
|
||||
|
||||
override fun readAltKey(): Boolean = altLatched
|
||||
|
||||
override fun readShiftKey(): Boolean = false
|
||||
|
||||
override fun readFnKey(): Boolean = false
|
||||
|
||||
override fun onLongPress(event: MotionEvent) = false
|
||||
override fun readControlKey() = viewModel.ctrlLatched
|
||||
override fun readAltKey() = viewModel.altLatched
|
||||
override fun readShiftKey() = false
|
||||
override fun readFnKey() = false
|
||||
override fun onCodePoint(
|
||||
codePoint: Int,
|
||||
ctrlDown: Boolean,
|
||||
session: TerminalSession,
|
||||
): Boolean {
|
||||
if (ctrlLatched || altLatched || pendingLatchedConsume) {
|
||||
consumeLatchedVisualState()
|
||||
}
|
||||
if (viewModel.ctrlLatched || viewModel.altLatched || viewModel.pendingLatchedConsume)
|
||||
viewModel.consumeLatchedVisualState()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -601,7 +378,7 @@ private fun TerminalPage(
|
||||
start = true,
|
||||
startOnlyIfCursorEnabled = true
|
||||
)
|
||||
syncOutput()
|
||||
viewModel.syncOutput { onOutputChange(it) }
|
||||
}
|
||||
|
||||
override fun logError(tag: String?, message: String?) {
|
||||
@@ -624,11 +401,7 @@ private fun TerminalPage(
|
||||
Log.v(tag ?: LOG_TAG, message.orEmpty())
|
||||
}
|
||||
|
||||
override fun logStackTraceWithMessage(
|
||||
tag: String?,
|
||||
message: String?,
|
||||
e: Exception?,
|
||||
) {
|
||||
override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) {
|
||||
Log.e(tag ?: LOG_TAG, message, e)
|
||||
}
|
||||
|
||||
@@ -638,50 +411,48 @@ private fun TerminalPage(
|
||||
}
|
||||
}
|
||||
|
||||
// reset on clear output
|
||||
LaunchedEffect(output) {
|
||||
if (output.isEmpty() && extractTranscript(terminalSession).isNotEmpty()) {
|
||||
if (output.isEmpty() && viewModel.extractTranscript(terminalSession).isNotEmpty()) {
|
||||
terminalSession.reset()
|
||||
applyTerminalThemeColors()
|
||||
applyTheme()
|
||||
terminalView?.mEmulator = terminalSession.emulator
|
||||
terminalView?.onScreenUpdated()
|
||||
syncOutput()
|
||||
viewModel.syncOutput { onOutputChange(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// auto-connect when tab becomes active
|
||||
LaunchedEffect(isActive) {
|
||||
if (!isActive) {
|
||||
onTerminalGestureLockChanged(false)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
customTypeface = loadTerminalTypeface(context)
|
||||
if (shellStream == null && !shellConnecting) {
|
||||
val connected = runCatching { NativeAdbService.isConnected() }.getOrDefault(false)
|
||||
if (connected) {
|
||||
openShellSession(showKeyboardAfterConnect = false)
|
||||
}
|
||||
}
|
||||
viewModel.autoConnectIfNeeded(::requestTerminalFocus)
|
||||
}
|
||||
|
||||
LaunchedEffect(colorScheme.surface, colorScheme.onSurface) {
|
||||
applyTerminalThemeColors()
|
||||
// theme changes
|
||||
LaunchedEffect(
|
||||
colorScheme.surface,
|
||||
colorScheme.onSurface
|
||||
) {
|
||||
applyTheme()
|
||||
terminalView?.onScreenUpdated()
|
||||
syncOutput()
|
||||
viewModel.syncOutput { onOutputChange(it) }
|
||||
}
|
||||
|
||||
// font size sync from storage
|
||||
LaunchedEffect(asBundle.terminalFontSizeSp) {
|
||||
if (terminalFontSizeSp != asBundle.terminalFontSizeSp) {
|
||||
updateFontSize(asBundle.terminalFontSizeSp)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
val latestFontSize = terminalFontSizeSp
|
||||
onTerminalGestureLockChanged(false)
|
||||
taskScope.launch {
|
||||
val latest = appSettings.bundleState.value
|
||||
if (latest.terminalFontSizeSp != latestFontSize) {
|
||||
appSettings.updateBundle {
|
||||
it.copy(terminalFontSizeSp = latestFontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
runCatching { shellStream?.close() }
|
||||
taskScope.cancel()
|
||||
viewModel.closeShell()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,15 +479,10 @@ private fun TerminalPage(
|
||||
setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() })
|
||||
setTypeface(customTypeface ?: Typeface.MONOSPACE)
|
||||
attachSession(terminalSession)
|
||||
applyTerminalThemeColors()
|
||||
applyTheme()
|
||||
setTerminalCursorBlinkerRate(500)
|
||||
setTerminalCursorBlinkerState(
|
||||
start = true,
|
||||
startOnlyIfCursorEnabled = true,
|
||||
)
|
||||
setOnTouchListener { _, event ->
|
||||
handleTerminalTouchInterception(this, event)
|
||||
}
|
||||
setTerminalCursorBlinkerState(start = true, startOnlyIfCursorEnabled = true)
|
||||
setOnTouchListener { _, event -> handleTerminalTouchInterception(this, event) }
|
||||
terminalView = this
|
||||
}
|
||||
},
|
||||
@@ -724,126 +490,118 @@ private fun TerminalPage(
|
||||
terminalView = it
|
||||
it.setTextSize(with(density) { terminalFontSizeSp.sp.roundToPx() })
|
||||
it.setTypeface(customTypeface ?: Typeface.MONOSPACE)
|
||||
if (it.currentSession !== terminalSession) {
|
||||
it.attachSession(terminalSession)
|
||||
}
|
||||
it.setOnTouchListener { _, event ->
|
||||
handleTerminalTouchInterception(it, event)
|
||||
}
|
||||
applyTerminalThemeColors()
|
||||
if (it.currentSession !== terminalSession) it.attachSession(terminalSession)
|
||||
it.setOnTouchListener { _, event -> handleTerminalTouchInterception(it, event) }
|
||||
applyTheme()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
TerminalExtraKeyButton(
|
||||
label = "ESC",
|
||||
modifier = Modifier.weight(1f),
|
||||
"ESC",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeLiteralKey("\u001B")
|
||||
viewModel.writeLiteralKey("")
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "/",
|
||||
modifier = Modifier.weight(1f),
|
||||
"/",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeLiteralKey("/")
|
||||
viewModel.writeLiteralKey("/")
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "-",
|
||||
modifier = Modifier.weight(1f),
|
||||
"-",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeLiteralKey("-")
|
||||
viewModel.writeLiteralKey("-")
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "HOME",
|
||||
modifier = Modifier.weight(1f),
|
||||
"HOME",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeSpecialKey(KeyEvent.KEYCODE_MOVE_HOME)
|
||||
viewModel.writeSpecialKey(KeyEvent.KEYCODE_MOVE_HOME)
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "↑",
|
||||
modifier = Modifier.weight(1f),
|
||||
"↑",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeSpecialKey(KeyEvent.KEYCODE_DPAD_UP)
|
||||
viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_UP)
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "END",
|
||||
modifier = Modifier.weight(1f),
|
||||
"END",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeSpecialKey(KeyEvent.KEYCODE_MOVE_END)
|
||||
viewModel.writeSpecialKey(KeyEvent.KEYCODE_MOVE_END)
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "PGUP",
|
||||
modifier = Modifier.weight(1f),
|
||||
"PGUP",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeSpecialKey(KeyEvent.KEYCODE_PAGE_UP)
|
||||
viewModel.writeSpecialKey(KeyEvent.KEYCODE_PAGE_UP)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
TerminalExtraKeyButton(
|
||||
label = "TAB",
|
||||
modifier = Modifier.weight(1f),
|
||||
"TAB",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeSpecialKey(KeyEvent.KEYCODE_TAB)
|
||||
viewModel.writeSpecialKey(KeyEvent.KEYCODE_TAB)
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "CTRL",
|
||||
active = ctrlLatched,
|
||||
modifier = Modifier.weight(1f),
|
||||
"CTRL",
|
||||
Modifier.weight(1f),
|
||||
active = viewModel.ctrlLatched
|
||||
) {
|
||||
haptic.contextClick()
|
||||
ctrlLatched = !ctrlLatched
|
||||
viewModel.ctrlLatched = !viewModel.ctrlLatched
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "ALT",
|
||||
active = altLatched,
|
||||
modifier = Modifier.weight(1f),
|
||||
"ALT",
|
||||
Modifier.weight(1f),
|
||||
active = viewModel.altLatched
|
||||
) {
|
||||
haptic.contextClick()
|
||||
altLatched = !altLatched
|
||||
viewModel.altLatched = !viewModel.altLatched
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "←",
|
||||
modifier = Modifier.weight(1f),
|
||||
"←",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeSpecialKey(KeyEvent.KEYCODE_DPAD_LEFT)
|
||||
viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_LEFT)
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "↓",
|
||||
modifier = Modifier.weight(1f),
|
||||
"↓",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeSpecialKey(KeyEvent.KEYCODE_DPAD_DOWN)
|
||||
viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_DOWN)
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "→",
|
||||
modifier = Modifier.weight(1f),
|
||||
"→",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeSpecialKey(KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||
viewModel.writeSpecialKey(KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||
}
|
||||
TerminalExtraKeyButton(
|
||||
label = "PGDN",
|
||||
modifier = Modifier.weight(1f),
|
||||
"PGDN",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
haptic.contextClick()
|
||||
writeSpecialKey(KeyEvent.KEYCODE_PAGE_DOWN)
|
||||
viewModel.writeSpecialKey(KeyEvent.KEYCODE_PAGE_DOWN)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -859,7 +617,6 @@ private fun TerminalExtraKeyButton(
|
||||
val content =
|
||||
if (active) colorScheme.primary
|
||||
else colorScheme.onSurface
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(32.dp)
|
||||
@@ -898,7 +655,7 @@ private fun TerminalOutputBottomSheet(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(2f / 3f),
|
||||
.fillMaxHeight(2f / 3f)
|
||||
) {
|
||||
item {
|
||||
TextField(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,6 @@ data class ClientOptions(
|
||||
// --crop=width:height:x:y
|
||||
var crop: String = "", // to server
|
||||
|
||||
// TODO: implement
|
||||
// --record
|
||||
var recordFilename: String = "",
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ androidxSecurityCrypto = "1.1.0"
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
|
||||
@@ -29,4 +29,5 @@ dependencyResolutionManagement {
|
||||
}
|
||||
|
||||
rootProject.name = "Scrcpy"
|
||||
includeBuild("submodule/miuix")
|
||||
include(":app")
|
||||
|
||||
1
submodule/miuix
Submodule
1
submodule/miuix
Submodule
Submodule submodule/miuix added at 81ad71b1c6
Reference in New Issue
Block a user