improve: multi profiles management

- 在 ScrcpyAllOptionsScreen 中添加配置管理功能,包括创建、重命名和删除配置
- 实现配置的标签页导航,并在切换配置时显示 snack
- 重构 PairingDialog 和 DeviceWidgets
- 引入 DeviceAdbBackgroundRunner 用于后台管理 ADB 连接
- 创建 DeviceAdbConnectionCoordinator 处理设备连接逻辑和状态管理
This commit is contained in:
Miuzarte
2026-04-12 17:31:38 +08:00
parent 436d74f888
commit 17ae903354
9 changed files with 869 additions and 536 deletions

View File

@@ -1,7 +1,9 @@
package io.github.miuzarte.scrcpyforandroid.models
import android.os.Parcelable
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import kotlinx.parcelize.Parcelize
// Composable 用, 不可变 List
class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut> by devices {
@@ -182,10 +184,11 @@ data class DeviceShortcut(
}
}
@Parcelize
data class ConnectionTarget(
val host: String,
val port: Int = Defaults.ADB_PORT,
) {
) : Parcelable {
override fun toString(): String = "$host:$port"
companion object {

View File

@@ -0,0 +1,136 @@
package io.github.miuzarte.scrcpyforandroid.pages
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
internal class DeviceTabAdbCallbacks(
private val runAdbConnect: (
label: String,
onStarted: (() -> Unit)?,
onFinished: (() -> Unit)?,
block: suspend () -> Unit,
) -> Unit,
private val runBusy: (
label: String,
onFinished: (() -> Unit)?,
block: suspend () -> Unit,
) -> Unit,
private val disconnectCurrentTargetBeforeConnecting: suspend (host: String, port: Int) -> Unit,
private val connectWithTimeout: suspend (host: String, port: Int) -> Unit,
private val handleAdbConnected: suspend (
host: String,
port: Int,
autoStartScrcpy: Boolean,
autoEnterFullScreen: Boolean,
scrcpyProfileId: String,
) -> Unit,
private val disconnectAdbConnection: suspend (
clearQuickOnlineForTarget: ConnectionTarget?,
logMessage: String?,
showSnackMessage: String?,
) -> Unit,
private val discoverPairingTarget: suspend () -> Pair<String, Int>?,
private val pairTarget: suspend (host: String, port: Int, code: String) -> Boolean,
private val isConnectedToTarget: (host: String, port: Int) -> Boolean,
private val onConnectionFailed: (Throwable) -> Unit,
private val onQuickConnectedChanged: (Boolean) -> Unit,
private val onBlackListHost: (String) -> Unit,
private val setActiveDeviceActionId: (String?) -> Unit,
) {
fun onDeviceAction(device: DeviceShortcut) {
val host = device.host
val port = device.port
val connected = isConnectedToTarget(host, port)
if (!connected) {
runAdbConnect(
"连接 ADB",
{ setActiveDeviceActionId(device.id) },
{ setActiveDeviceActionId(null) },
) {
disconnectCurrentTargetBeforeConnecting(host, port)
try {
connectWithTimeout(host, port)
handleAdbConnected(
host,
port,
device.startScrcpyOnConnect,
device.startScrcpyOnConnect && device.openFullscreenOnStart,
device.scrcpyProfileId,
)
onQuickConnectedChanged(false)
} catch (error: Exception) {
onConnectionFailed(error)
}
}
return
}
runAdbConnect(
"断开 ADB",
{ setActiveDeviceActionId(device.id) },
{ setActiveDeviceActionId(null) },
) {
onBlackListHost(host)
disconnectAdbConnection(
ConnectionTarget(host, port),
"ADB 已断开: ${device.name}",
"ADB 已断开",
)
}
}
fun onQuickConnect(target: ConnectionTarget) {
runAdbConnect(
"连接 ADB",
{ setActiveDeviceActionId(target.toString()) },
{ setActiveDeviceActionId(null) },
) {
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
try {
connectWithTimeout(target.host, target.port)
handleAdbConnected(
target.host,
target.port,
false,
false,
ScrcpyOptions.GLOBAL_PROFILE_ID,
)
onQuickConnectedChanged(true)
} catch (error: Exception) {
onConnectionFailed(error)
}
}
}
fun onDisconnectCurrent(target: ConnectionTarget?) {
runAdbConnect(
"断开 ADB",
null,
null,
) {
target?.let {
onBlackListHost(it.host)
disconnectAdbConnection(
it,
"ADB 已断开",
"ADB 已断开",
)
}
}
}
fun onPair(host: String, port: String, code: String) {
runBusy("执行配对", null) {
val resolvedHost = host.trim()
val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy
val resolvedCode = code.trim()
pairTarget(resolvedHost, resolvedPort, resolvedCode)
}
}
suspend fun onDiscoverPairingTarget(): Pair<String, Int>? {
return discoverPairingTarget()
}
}

View File

@@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -26,6 +25,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.github.miuzarte.scrcpyforandroid.StreamActivity
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
@@ -33,16 +35,18 @@ import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
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.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbBackgroundRunner
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbConnectionCoordinator
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbSessionState
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
@@ -66,7 +70,6 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.DropdownImpl
import top.yukonga.miuix.kmp.basic.Icon
@@ -81,8 +84,6 @@ import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme
import java.net.InetSocketAddress
import java.net.Socket
private const val ADB_CONNECT_TIMEOUT_MS = 12_000L
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L
@@ -163,6 +164,9 @@ fun DeviceTabPage(
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
val adbCoordinator = remember { DeviceAdbConnectionCoordinator() }
val adbBackgroundRunner = remember { DeviceAdbBackgroundRunner() }
val lifecycleOwner = LocalLifecycleOwner.current
val haptics = LocalAppHaptics.current
val navigator = LocalRootNavigator.current
@@ -218,32 +222,43 @@ fun DeviceTabPage(
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
val scrcpyProfilesState by scrcpyProfiles.state.collectAsState()
var isAppInForeground by rememberSaveable { mutableStateOf(true) }
DisposableEffect(Unit) {
onDispose {
AppWakeLocks.release()
}
}
DisposableEffect(adbBackgroundRunner) {
onDispose {
adbBackgroundRunner.close()
}
}
DisposableEffect(lifecycleOwner) {
isAppInForeground =
lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> isAppInForeground = true
Lifecycle.Event.ON_STOP -> isAppInForeground = false
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// Run adb operations on a dedicated single thread.
// Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic.
var busy by rememberSaveable { mutableStateOf(false) }
var statusLine by rememberSaveable { mutableStateOf("未连接") }
var adbConnected by rememberSaveable { mutableStateOf(false) }
var isQuickConnected by rememberSaveable { mutableStateOf(false) }
var currentTargetHost by rememberSaveable { mutableStateOf("") }
var currentTargetPort by rememberSaveable { mutableIntStateOf(Defaults.ADB_PORT) }
var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") }
var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) }
val sessionInfo by scrcpy.currentSessionState.collectAsState()
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
var adbConnecting by rememberSaveable { mutableStateOf(false) }
var connectedScrcpyProfileId by rememberSaveable {
mutableStateOf(ScrcpyOptions.GLOBAL_PROFILE_ID)
}
var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) }
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
val listState = rememberLazyListState()
val isPreviewCardVisible by remember(listState) {
@@ -252,10 +267,14 @@ fun DeviceTabPage(
}
}
val currentTarget =
if (currentTargetHost.isNotBlank())
ConnectionTarget(currentTargetHost, currentTargetPort)
else null
val adbConnected = adbSession.isConnected
val statusLine = adbSession.statusLine
val isQuickConnected = adbSession.isQuickConnected
val currentTarget = adbSession.currentTarget
val connectedDeviceLabel = adbSession.connectedDeviceLabel
val connectedScrcpyProfileId = adbSession.connectedScrcpyProfileId
val audioForwardingSupported = adbSession.audioForwardingSupported
val cameraMirroringSupported = adbSession.cameraMirroringSupported
fun resolveScrcpyBundle(profileId: String): ScrcpyOptions.Bundle {
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
@@ -317,17 +336,10 @@ fun DeviceTabPage(
) {
// Also stops scrcpy.
runCatching { scrcpy.stop() }
runCatching { NativeAdbService.disconnect() }
runCatching { adbCoordinator.disconnect() }
AppWakeLocks.release()
adbConnected = false
currentTargetHost = ""
currentTargetPort = Defaults.ADB_PORT
connectedScrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID
adbSession = DeviceAdbSessionState()
AppRuntime.currentConnectionTarget = null
audioForwardingSupported = true
cameraMirroringSupported = true
statusLine = "未连接"
connectedDeviceLabel = "未连接"
clearQuickOnlineForTarget?.let { target ->
if (target.host.isNotBlank())
savedShortcuts = savedShortcuts.update(
@@ -353,7 +365,7 @@ fun DeviceTabPage(
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
val currentBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
val audioSupported = sdkInt !in 0..<30
audioForwardingSupported = audioSupported
adbSession = adbSession.copy(audioForwardingSupported = audioSupported)
if (!audioSupported && currentBundle.audio) {
scope.launch {
if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
@@ -371,7 +383,7 @@ fun DeviceTabPage(
)
}
val cameraSupported = sdkInt !in 0..<31
cameraMirroringSupported = cameraSupported
adbSession = adbSession.copy(cameraMirroringSupported = cameraSupported)
if (!cameraSupported && currentBundle.videoSource == "camera") {
scope.launch {
if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
@@ -403,9 +415,7 @@ fun DeviceTabPage(
* indefinitely. Use a small, caller-chosen timeout to keep UX snappy.
*/
suspend fun connectWithTimeout(host: String, port: Int) {
return withTimeout(ADB_CONNECT_TIMEOUT_MS) {
NativeAdbService.connect(host, port)
}
adbCoordinator.connectWithTimeout(host, port, ADB_CONNECT_TIMEOUT_MS)
}
/**
@@ -422,10 +432,7 @@ fun DeviceTabPage(
* echo check helps detect that state.
*/
suspend fun keepAliveCheck(host: String, port: Int): Boolean {
return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
val connected = NativeAdbService.isConnected()
return@withTimeout connected
}
return adbCoordinator.isConnected(ADB_KEEPALIVE_TIMEOUT_MS)
}
/**
@@ -436,14 +443,7 @@ fun DeviceTabPage(
* - Returns true when TCP handshake succeeds within [ADB_TCP_PROBE_TIMEOUT_MS].
*/
suspend fun probeTcpReachable(host: String, port: Int): Boolean {
return withContext(Dispatchers.IO) {
runCatching {
Socket().use { socket ->
socket.connect(InetSocketAddress(host, port), ADB_TCP_PROBE_TIMEOUT_MS)
true
}
}.getOrDefault(false)
}
return adbCoordinator.probeTcpReachable(host, port, ADB_TCP_PROBE_TIMEOUT_MS)
}
/**
@@ -519,47 +519,45 @@ fun DeviceTabPage(
}
}
suspend fun runAutoAdbConnect(host: String, port: Int) {
runCatching {
suspend fun runAutoAdbConnect(host: String, port: Int): Boolean {
return runCatching {
connectWithTimeout(host, port)
true
}.getOrElse { error ->
val detail = error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
logEvent("自动重连失败: $host:$port ($detail)", Log.WARN)
false
}
}
LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) {
if (!adbConnected || currentTargetHost.isBlank()) return@LaunchedEffect
// Keep-alive loop for current target.
// On failure: try to reconnect once; if failed, fully disconnect and reset UI state.
val host = currentTargetHost
val port = currentTargetPort
while (adbConnected && currentTargetHost == host && currentTargetPort == port) {
delay(ADB_KEEPALIVE_INTERVAL_MS)
val alive = runCatching { keepAliveCheck(host, port) }.getOrElse { false }
if (alive) continue
logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN)
try {
connectWithTimeout(host, port)
adbConnected = true
statusLine = "$host:$port"
LaunchedEffect(adbConnected, currentTarget, isAppInForeground) {
if (!adbConnected || currentTarget == null) return@LaunchedEffect
adbBackgroundRunner.runKeepAliveLoop(
sessionState = { adbSession },
isForeground = { isAppInForeground },
intervalMs = ADB_KEEPALIVE_INTERVAL_MS,
keepAliveCheck = ::keepAliveCheck,
reconnect = ::connectWithTimeout,
onReconnectSuccess = { host, port ->
logEvent("ADB 自动重连成功: $host:$port")
adbSession = adbSession.copy(
isConnected = true,
statusLine = "$host:$port",
)
snackbar.show("ADB 自动重连成功")
} catch (e: Exception) {
},
onReconnectFailure = { error ->
disconnectAdbConnection()
statusLine = "ADB 连接断开"
logEvent("ADB 自动重连失败: $e", Log.ERROR)
adbSession = adbSession.copy(statusLine = "ADB 连接断开")
logEvent("ADB 自动重连失败: $error", Log.ERROR)
snackbar.show("ADB 自动重连失败")
break
}
}
},
)
}
suspend fun resolveStartAppRequest(
scrcpy: Scrcpy,
options: io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions,
options: ClientOptions,
): StartAppRequest? {
val raw = options.startApp.trim()
if (raw.isBlank()) {
@@ -588,14 +586,20 @@ fun DeviceTabPage(
}
val searchName = query.drop(1).trim()
require(searchName.isNotBlank()) { "应用名称不能为空" }
require(searchName.isNotBlank()) {
"应用名不能为空"
}
val apps = scrcpy.listings.getApps(forceRefresh = false)
val matches = apps.filter { it.label.startsWith(searchName, ignoreCase = true) }
val matches = apps.filter {
it.label.startsWith(searchName, ignoreCase = true)
}
require(matches.isNotEmpty()) { "未找到应用名以 \"$searchName\" 开头的应用" }
require(matches.isNotEmpty()) {
"未找到应用名以 \"$searchName\" 开头的应用"
}
require(matches.size == 1) {
"按名匹配到多个应用: " +
"应用名匹配到多个应用: " +
matches.take(5).joinToString { "${it.label} (${it.packageName})" }
}
@@ -632,7 +636,7 @@ fun DeviceTabPage(
)
}
runCatching {
NativeAdbService.startApp(
adbCoordinator.startApp(
packageName = request.packageName,
displayId = request.displayId,
forceStop = request.forceStop,
@@ -659,7 +663,7 @@ fun DeviceTabPage(
if (options.disableScreensaver)
AppWakeLocks.acquire()
statusLine = "scrcpy 运行中"
adbSession = adbSession.copy(statusLine = "scrcpy 运行中")
@SuppressLint("DefaultLocale")
val videoDetail =
if (!options.video) "off"
@@ -703,26 +707,29 @@ fun DeviceTabPage(
autoEnterFullScreen: Boolean = false,
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
) {
currentTargetHost = host
currentTargetPort = port
connectedScrcpyProfileId = scrcpyProfileId
AppRuntime.currentConnectionTarget = ConnectionTarget(host, port)
val target = ConnectionTarget(host, port)
adbSession = adbSession.copy(
isConnected = true,
currentTarget = target,
connectedScrcpyProfileId = scrcpyProfileId,
statusLine = "$host:$port",
)
AppRuntime.currentConnectionTarget = target
val info = fetchConnectedDeviceInfo(NativeAdbService, host, port)
val info = adbCoordinator.fetchConnectedDeviceInfo(host, port)
val fullLabel = if (info.serial.isNotBlank()) {
"${info.model} (${info.serial})"
} else {
info.model
}
connectedDeviceLabel = info.model
adbSession = adbSession.copy(connectedDeviceLabel = info.model)
applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease)
savedShortcuts = savedShortcuts.update(
host = host, port = port,
name = fullLabel,
updateNameOnlyWhenEmpty = true
)
statusLine = "$host:$port"
logEvent(
"ADB 已连接: " +
@@ -744,119 +751,126 @@ fun DeviceTabPage(
LaunchedEffect(
adbConnected,
asBundle.adbAutoReconnectPairedDevice,
asBundle.adbMdnsLanDiscovery
asBundle.adbMdnsLanDiscovery,
isAppInForeground,
) {
if (adbConnected || !asBundle.adbAutoReconnectPairedDevice) return@LaunchedEffect
// Background auto reconnect pipeline:
// 1) try quick list targets with reachable TCP ports
// 2) fallback to mDNS discovery
val quickConnectTriedOnce = mutableSetOf<String>()
while (!adbConnected) {
if (busy || adbConnecting || sessionInfo != null) {
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
continue
}
val quickCandidates = savedShortcuts.toList()
if (quickCandidates.isNotEmpty()) {
for (target in quickCandidates) {
if (adbConnected || adbConnecting) break
if (sessionReconnectBlacklistHosts.contains(target.host)) continue
val targetKey = "${target.host}:${target.port}"
if (quickConnectTriedOnce.contains(targetKey)) continue
val portReachable = probeTcpReachable(target.host, target.port)
if (!portReachable) continue
quickConnectTriedOnce += targetKey
try {
runAutoAdbConnect(target.host, target.port)
adbConnected = true
savedShortcuts = savedShortcuts.update(
host = target.host, port = target.port,
)
handleAdbConnected(
target.host,
target.port,
scrcpyProfileId = target.scrcpyProfileId,
)
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
} catch (_: Exception) {
}
break
}
if (adbConnected) break
}
val discovered = withContext(Dispatchers.IO) {
NativeAdbService.discoverConnectService(
adbBackgroundRunner.runAutoReconnectLoop(
isConnected = { adbConnected },
isForeground = { isAppInForeground },
isAutoReconnectEnabled = { asBundle.adbAutoReconnectPairedDevice },
isBusy = { busy },
isAdbConnecting = { adbConnecting },
hasActiveSession = { sessionInfo != null },
savedShortcuts = { savedShortcuts.toList() },
isBlacklisted = { host -> sessionReconnectBlacklistHosts.contains(host) },
probeTcpReachable = ::probeTcpReachable,
discoverConnectService = {
adbCoordinator.discoverConnectService(
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
includeLanDevices = asBundle.adbMdnsLanDiscovery,
)
}
if (discovered == null) {
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
continue
}
val (discoveredHost, discoveredPort) = discovered
if (sessionReconnectBlacklistHosts.contains(discoveredHost)) {
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
continue
}
val knownDevice = savedShortcuts.firstOrNull { it.host == discoveredHost }
if (knownDevice == null) {
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
continue
}
val portToReplace = savedShortcuts.firstOrNull {
it.host == discoveredHost &&
it.port != Defaults.ADB_PORT &&
it.port != discoveredPort
}?.port
if (portToReplace != null) {
},
onMdnsPortChanged = { host, oldPort, newPort ->
savedShortcuts = savedShortcuts.update(
host = discoveredHost, port = portToReplace,
newPort = discoveredPort,
host = host,
port = oldPort,
newPort = newPort,
)
logEvent(
"mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort"
)
}
if (adbConnected || adbConnecting) {
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
continue
}
try {
runAutoAdbConnect(discoveredHost, discoveredPort)
adbConnected = true
savedShortcuts = savedShortcuts.update(
host = discoveredHost, port = discoveredPort,
)
handleAdbConnected(
discoveredHost,
discoveredPort,
scrcpyProfileId = knownDevice.scrcpyProfileId,
)
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
} catch (_: Exception) {
}
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
}
logEvent("mDNS 发现新端口,已更新快速设备: $host:$oldPort -> $host:$newPort")
},
connectKnownShortcut = { target ->
if (!runAutoAdbConnect(target.host, target.port)) {
false
} else {
savedShortcuts = savedShortcuts.update(
host = target.host,
port = target.port,
)
handleAdbConnected(
target.host,
target.port,
scrcpyProfileId = target.scrcpyProfileId,
)
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
true
}
},
connectDiscoveredShortcut = { host, port, knownDevice ->
if (!runAutoAdbConnect(host, port)) {
false
} else {
savedShortcuts = savedShortcuts.update(host = host, port = port)
handleAdbConnected(
host,
port,
scrcpyProfileId = knownDevice.scrcpyProfileId,
)
logEvent("ADB 自动重连成功: $host:$port")
true
}
},
retryIntervalMs = ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS,
)
}
fun sendVirtualButtonAction(action: VirtualButtonAction) {
val keycode = action.keycode ?: return
runBusy("发送 ${action.title}") {
scrcpy.injectKeycode(0, keycode)
scrcpy.injectKeycode(1, keycode)
}
}
val adbCallbacks = DeviceTabAdbCallbacks(
runAdbConnect = { label, onStarted, onFinished, block ->
runAdbConnect(label, onStarted, onFinished, block)
},
runBusy = { label, onFinished, block ->
runBusy(label, onFinished, block)
},
disconnectCurrentTargetBeforeConnecting = { host, port ->
disconnectCurrentTargetBeforeConnecting(host, port)
},
connectWithTimeout = { host, port ->
connectWithTimeout(host, port)
},
handleAdbConnected = { host, port, autoStartScrcpy, autoEnterFullScreen, scrcpyProfileId ->
handleAdbConnected(
host = host,
port = port,
autoStartScrcpy = autoStartScrcpy,
autoEnterFullScreen = autoEnterFullScreen,
scrcpyProfileId = scrcpyProfileId,
)
},
disconnectAdbConnection = { clearQuickOnlineForTarget, logMessage, showSnackMessage ->
disconnectAdbConnection(
clearQuickOnlineForTarget = clearQuickOnlineForTarget,
logMessage = logMessage,
showSnackMessage = showSnackMessage,
)
},
discoverPairingTarget = {
adbCoordinator.discoverPairingService(
includeLanDevices = asBundle.adbMdnsLanDiscovery,
)
},
pairTarget = { host, port, code ->
val ok = adbCoordinator.pair(host, port, code)
logEvent(
if (ok) "配对成功" else "配对失败",
if (ok) Log.INFO else Log.ERROR
)
snackbar.show(if (ok) "配对成功" else "配对失败")
ok
},
isConnectedToTarget = { host, port ->
adbConnected && currentTarget?.host == host && currentTarget.port == port
},
onConnectionFailed = { error ->
adbSession = adbSession.copy(statusLine = "ADB 连接失败")
logEvent("ADB 连接失败: $error", Log.ERROR)
snackbar.show("ADB 连接失败")
},
onQuickConnectedChanged = { quickConnected ->
adbSession = adbSession.copy(isQuickConnected = quickConnected)
},
onBlackListHost = { host -> sessionReconnectBlacklistHosts += host },
setActiveDeviceActionId = { activeDeviceActionId = it },
)
// 设备
LazyColumn(
@@ -907,55 +921,7 @@ fun DeviceTabPage(
onAction = { device ->
haptics.contextClick()
if (editingDeviceId == device.id) editingDeviceId = null
val host = device.host
val port = device.port
val connected = adbConnected
&& currentTarget?.host == host
&& currentTarget.port == port
if (!connected) {
runAdbConnect(
"连接 ADB",
onStarted = { activeDeviceActionId = device.id },
onFinished = { activeDeviceActionId = null },
) {
disconnectCurrentTargetBeforeConnecting(host, port)
try {
connectWithTimeout(host, port)
adbConnected = true
isQuickConnected = false
savedShortcuts = savedShortcuts.update(
host = host, port = port,
)
handleAdbConnected(
host = host, port = port,
autoStartScrcpy = device.startScrcpyOnConnect,
autoEnterFullScreen = device.startScrcpyOnConnect
&& device.openFullscreenOnStart,
scrcpyProfileId = device.scrcpyProfileId,
)
} catch (e: Exception) {
statusLine = "ADB 连接失败"
logEvent("ADB 连接失败: $e", Log.ERROR)
snackbar.show("ADB 连接失败")
}
}
} else {
activeDeviceActionId = device.id
runAdbConnect(
"断开 ADB",
onStarted = { activeDeviceActionId = device.id },
onFinished = { activeDeviceActionId = null },
) {
sessionReconnectBlacklistHosts += host
disconnectAdbConnection(
clearQuickOnlineForTarget = ConnectionTarget(host, port),
logMessage = "ADB 已断开: ${device.name}",
showSnackMessage = "ADB 已断开",
)
}
}
adbCallbacks.onDeviceAction(device)
},
onEditorSave = { device, updated ->
savedShortcuts = savedShortcuts.update(
@@ -1000,30 +966,7 @@ fun DeviceTabPage(
onConnect = {
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
?: return@QuickConnectCard
runAdbConnect(
"连接 ADB",
onStarted = { activeDeviceActionId = target.toString() },
onFinished = { activeDeviceActionId = null },
) {
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
try {
connectWithTimeout(target.host, target.port)
adbConnected = true
isQuickConnected = true // 标记为快速连接
savedShortcuts = savedShortcuts.update(
host = target.host, port = target.port,
)
handleAdbConnected(
target.host,
target.port,
scrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID,
)
} catch (e: Exception) {
statusLine = "ADB 连接失败"
logEvent("ADB 连接失败: $e", Log.ERROR)
snackbar.show("ADB 连接失败")
}
}
adbCallbacks.onQuickConnect(target)
},
)
}
@@ -1034,28 +977,8 @@ fun DeviceTabPage(
PairingCard(
busy = busy,
autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen,
onDiscoverTarget = {
NativeAdbService.discoverPairingService(
includeLanDevices = asBundle.adbMdnsLanDiscovery,
)
},
onPair = { host, port, code ->
runBusy("执行配对") {
val resolvedHost = host.trim()
val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy
val resolvedCode = code.trim()
val ok = NativeAdbService.pair(
resolvedHost,
resolvedPort,
resolvedCode,
)
logEvent(
if (ok) "配对成功" else "配对失败",
if (ok) Log.INFO else Log.ERROR
)
snackbar.show(if (ok) "配对成功" else "配对失败")
}
},
onDiscoverTarget = adbCallbacks::onDiscoverPairingTarget,
onPair = adbCallbacks::onPair,
)
}
}
@@ -1080,28 +1003,15 @@ fun DeviceTabPage(
runBusy("停止 scrcpy") {
scrcpy.stop()
AppWakeLocks.release()
statusLine = "${currentTarget!!.host}:${currentTarget.port}"
adbSession = adbSession.copy(
statusLine = "${currentTarget!!.host}:${currentTarget.port}"
)
logEvent("scrcpy 已停止")
snackbar.show("scrcpy 已停止")
}
},
sessionInfo = sessionInfo,
onDisconnect = {
runAdbConnect(
"断开 ADB",
onStarted = {},
onFinished = {},
) {
currentTarget?.let { target ->
sessionReconnectBlacklistHosts += target.host
disconnectAdbConnection(
clearQuickOnlineForTarget = target,
logMessage = "ADB 已断开",
showSnackMessage = "ADB 已断开",
)
}
}
},
onDisconnect = { adbCallbacks.onDisconnectCurrent(currentTarget) },
)
}
@@ -1129,7 +1039,13 @@ fun DeviceTabPage(
outsideActions = virtualButtonLayout.first,
moreActions = virtualButtonLayout.second,
showText = asBundle.previewVirtualButtonShowText,
onAction = ::sendVirtualButtonAction,
onAction = { action ->
val keycode = action.keycode ?: return@VirtualButtonCard
runBusy("发送 ${action.title}") {
scrcpy.injectKeycode(0, keycode)
scrcpy.injectKeycode(1, keycode)
}
},
)
}
}

View File

@@ -51,6 +51,7 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
import kotlinx.coroutines.CoroutineScope
@@ -108,7 +109,7 @@ fun FullscreenControlScreen(
)
}
val floatingActions = remember(buttonItems) {
(buttonItems.first + buttonItems.second).filter { it != io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction.MORE }
(buttonItems.first + buttonItems.second).filter { it != VirtualButtonAction.MORE }
}
val fullscreenDebugInfo = asBundle.fullscreenDebugInfo
val showFullscreenVirtualButtons = asBundle.showFullscreenVirtualButtons

View File

@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
@@ -59,6 +58,7 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyProfiles
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
@@ -70,6 +70,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.DropdownImpl
import top.yukonga.miuix.kmp.basic.Icon
@@ -81,6 +82,7 @@ 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.TabRowWithContour
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
@@ -96,6 +98,7 @@ import top.yukonga.miuix.kmp.preference.ArrowPreference
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference
import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
import kotlin.math.roundToInt
@Composable
@@ -105,6 +108,7 @@ internal fun ScrcpyAllOptionsScreen(
) {
val navigator = LocalRootNavigator.current
val scope = rememberCoroutineScope()
val snackbar = LocalSnackbarController.current
var showProfileMenu by rememberSaveable { mutableStateOf(false) }
var showManageProfilesSheet by rememberSaveable { mutableStateOf(false) }
val qdBundleShared by quickDevices.bundleState.collectAsState()
@@ -139,6 +143,15 @@ internal fun ScrcpyAllOptionsScreen(
val lastValidSoBundleState = rememberSaveable(selectedProfileId) {
mutableStateOf(soBundleState.value)
}
val profileTabs = remember(scrcpyProfilesState.profiles) {
scrcpyProfilesState.profiles.map { it.name }
}
val profileIds = remember(scrcpyProfilesState.profiles) {
scrcpyProfilesState.profiles.map { it.id }
}
val selectedProfileIndex = remember(selectedProfileId, profileIds) {
profileIds.indexOf(selectedProfileId).coerceAtLeast(0)
}
val currentConnectedDeviceName = remember(qdBundleShared.quickDevicesList) {
val currentTarget = AppRuntime.currentConnectionTarget
if (currentTarget == null) {
@@ -153,7 +166,8 @@ internal fun ScrcpyAllOptionsScreen(
}
var activeProfileDialog by rememberSaveable { mutableStateOf<ProfileDialogMode?>(null) }
var profileDialogTargetId by rememberSaveable { mutableStateOf<String?>(null) }
var profileDialogInput by rememberSaveable { mutableStateOf("新配置") }
var profileDialogInput by rememberSaveable { mutableStateOf("") }
var profileDialogCopySourceId by rememberSaveable { mutableStateOf<String?>(null) }
var deletingProfileId by rememberSaveable { mutableStateOf<String?>(null) }
suspend fun saveBundleForProfile(profileId: String, bundle: ScrcpyOptions.Bundle) {
@@ -182,6 +196,21 @@ internal fun ScrcpyAllOptionsScreen(
}
}
suspend fun bindCurrentConnectedDevice(profileId: String) {
val target = AppRuntime.currentConnectionTarget ?: return
val shortcuts = DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList)
val updated = shortcuts.update(
host = target.host,
port = target.port,
scrcpyProfileId = profileId,
)
if (updated != shortcuts) {
quickDevices.updateBundle { bundle ->
bundle.copy(quickDevicesList = updated.marshalToString())
}
}
}
Scaffold(
topBar = {
TopAppBar(
@@ -214,6 +243,31 @@ internal fun ScrcpyAllOptionsScreen(
)
},
scrollBehavior = scrollBehavior,
bottomContent = {
TabRow(
tabs = profileTabs,
selectedTabIndex = selectedProfileIndex,
onTabSelected = { index ->
val nextProfileId = profileIds.getOrNull(index)
?: return@TabRow
if (nextProfileId == selectedProfileId) return@TabRow
scope.launch {
saveBundleForProfile(selectedProfileId, soBundleState.value)
bindCurrentConnectedDevice(nextProfileId)
selectedProfileId = nextProfileId
val profileName = profileTabs.getOrElse(index) { "全局" }
currentConnectedDeviceName?.let { deviceName ->
snackbar.show("$deviceName 已切换到配置 $profileName")
}
}
},
modifier = Modifier.padding(bottom = UiSpacing.Medium).padding(horizontal = UiSpacing.Medium),
minWidth = 96.dp,
maxWidth = 192.dp,
height = 48.dp,
itemSpacing = UiSpacing.Medium,
)
}
)
},
snackbarHost = {
@@ -225,51 +279,59 @@ internal fun ScrcpyAllOptionsScreen(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
scrcpy = scrcpy,
qdBundleShared = qdBundleShared,
soBundleShared = soBundleShared,
scrcpyProfilesState = scrcpyProfilesState,
selectedProfileIdState = selectedProfileIdState,
soBundleState = soBundleState,
lastValidSoBundleState = lastValidSoBundleState,
currentConnectedDeviceName = currentConnectedDeviceName,
onSaveBundleForProfile = ::saveBundleForProfile,
)
ProfileNameDialog(
mode = activeProfileDialog,
initialInput = profileDialogInput,
profiles = scrcpyProfilesState.profiles,
initialCopySourceProfileId = profileDialogCopySourceId,
onDismissRequest = {
activeProfileDialog = null
profileDialogTargetId = null
},
onConfirm = { input ->
scope.launch {
when (activeProfileDialog) {
ProfileDialogMode.Create -> {
saveBundleForProfile(selectedProfileId, soBundleState.value)
val created = scrcpyProfiles.createProfile(
requestedName = input,
bundle = soBundleState.value,
)
selectedProfileId = created.id
) { input, copySourceProfileId ->
scope.launch {
when (activeProfileDialog) {
ProfileDialogMode.Create -> {
saveBundleForProfile(selectedProfileId, soBundleState.value)
val copySourceBundle = when (copySourceProfileId) {
null -> ScrcpyOptions.defaultBundle()
selectedProfileId -> soBundleState.value
ScrcpyOptions.GLOBAL_PROFILE_ID -> soBundleShared
else -> scrcpyProfilesState.profiles
.firstOrNull { it.id == copySourceProfileId }
?.bundle
?: soBundleShared
}
ProfileDialogMode.Rename -> {
val profileId = profileDialogTargetId ?: return@launch
scrcpyProfiles.renameProfile(
id = profileId,
requestedName = input,
)
}
null -> Unit
val created = scrcpyProfiles.createProfile(
requestedName = input,
bundle = copySourceBundle,
)
selectedProfileId = created.id
}
profileDialogTargetId = null
activeProfileDialog = null
ProfileDialogMode.Rename -> {
val profileId = profileDialogTargetId ?: return@launch
scrcpyProfiles.renameProfile(
id = profileId,
requestedName = input,
)
}
null -> Unit
}
},
)
profileDialogTargetId = null
profileDialogCopySourceId = selectedProfileId
activeProfileDialog = null
}
}
ManageProfilesSheet(
show = showManageProfilesSheet,
@@ -278,7 +340,8 @@ internal fun ScrcpyAllOptionsScreen(
onDismissRequest = { showManageProfilesSheet = false },
onCreateProfile = {
profileDialogTargetId = null
profileDialogInput = ""
profileDialogInput = "新配置"
profileDialogCopySourceId = selectedProfileId
activeProfileDialog = ProfileDialogMode.Create
},
onRenameProfile = { profileId ->
@@ -304,20 +367,19 @@ internal fun ScrcpyAllOptionsScreen(
.firstOrNull { it.id == deletingProfileId }
?.name.orEmpty(),
onDismissRequest = { deletingProfileId = null },
onConfirm = {
scope.launch {
val profileId = deletingProfileId ?: return@launch
val deleted = scrcpyProfiles.deleteProfile(profileId)
if (deleted) {
rebindDeletedProfileReferences(profileId)
if (selectedProfileId == profileId) {
selectedProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID
}
) {
scope.launch {
val profileId = deletingProfileId ?: return@launch
val deleted = scrcpyProfiles.deleteProfile(profileId)
if (deleted) {
rebindDeletedProfileReferences(profileId)
if (selectedProfileId == profileId) {
selectedProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID
}
deletingProfileId = null
}
},
)
deletingProfileId = null
}
}
}
}
@@ -326,13 +388,11 @@ internal fun ScrcpyAllOptionsPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
scrcpy: Scrcpy,
qdBundleShared: io.github.miuzarte.scrcpyforandroid.storage.QuickDevices.Bundle,
soBundleShared: ScrcpyOptions.Bundle,
scrcpyProfilesState: io.github.miuzarte.scrcpyforandroid.storage.ScrcpyProfiles.State,
scrcpyProfilesState: ScrcpyProfiles.State,
selectedProfileIdState: MutableState<String>,
soBundleState: MutableState<ScrcpyOptions.Bundle>,
lastValidSoBundleState: MutableState<ScrcpyOptions.Bundle>,
currentConnectedDeviceName: String?,
onSaveBundleForProfile: suspend (String, ScrcpyOptions.Bundle) -> Unit,
) {
val focusManager = LocalFocusManager.current
@@ -348,36 +408,12 @@ internal fun ScrcpyAllOptionsPage(
var soBundle by soBundleState
var lastValidSoBundle by lastValidSoBundleState
val soBundleLatest by rememberUpdatedState(soBundle)
val profileTabs = remember(scrcpyProfilesState.profiles) {
scrcpyProfilesState.profiles.map { it.name }
}
val profileIds = remember(scrcpyProfilesState.profiles) {
scrcpyProfilesState.profiles.map { it.id }
}
val selectedProfileIndex = remember(selectedProfileId, profileIds) {
profileIds.indexOf(selectedProfileId).coerceAtLeast(0)
}
fun resolveProfileBundle(profileId: String): ScrcpyOptions.Bundle {
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) return soBundleShared
return scrcpyProfilesState.profiles.firstOrNull { it.id == profileId }?.bundle
?: soBundleShared
}
suspend fun bindCurrentConnectedDevice(profileId: String) {
val target = AppRuntime.currentConnectionTarget ?: return
val shortcuts = DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList)
val updated = shortcuts.update(
host = target.host,
port = target.port,
scrcpyProfileId = profileId,
)
if (updated != shortcuts) {
quickDevices.updateBundle { bundle ->
bundle.copy(quickDevicesList = updated.marshalToString())
}
}
}
LaunchedEffect(selectedProfileId, soBundleShared, scrcpyProfilesState) {
val bundle = resolveProfileBundle(selectedProfileId)
if (soBundle != bundle) {
@@ -715,32 +751,6 @@ internal fun ScrcpyAllOptionsPage(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
item {
TabRowWithContour(
tabs = profileTabs,
selectedTabIndex = selectedProfileIndex,
onTabSelected = { index ->
val nextProfileId = profileIds.getOrNull(index) ?: return@TabRowWithContour
if (nextProfileId == selectedProfileId) return@TabRowWithContour
scope.launch {
onSaveBundleForProfile(selectedProfileId, soBundle)
bindCurrentConnectedDevice(nextProfileId)
selectedProfileId = nextProfileId
val profileName = profileTabs.getOrElse(index) { "全局" }
currentConnectedDeviceName?.let { deviceName ->
snackbar.show("$deviceName 已切换到配置 $profileName")
}
}
},
// TODO
// listState = contourTabListState,
modifier = Modifier.padding(bottom = UiSpacing.ContentVertical),
minWidth = 96.dp,
maxWidth = 128.dp,
height = 64.dp,
)
}
item {
Card {
TextField(
@@ -1922,7 +1932,7 @@ private fun ProfileMenuPopupItem(
top = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem,
bottom = if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem,
),
color = top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme.disabledOnSecondaryVariant,
color = MiuixTheme.colorScheme.disabledOnSecondaryVariant,
fontWeight = FontWeight.Medium,
)
}
@@ -1931,19 +1941,32 @@ private fun ProfileMenuPopupItem(
private fun ProfileNameDialog(
mode: ProfileDialogMode?,
initialInput: String,
profiles: List<ScrcpyProfiles.Profile>,
initialCopySourceProfileId: String?,
onDismissRequest: () -> Unit,
onConfirm: (String) -> Unit,
onConfirm: (String, String?) -> Unit,
) {
if (mode == null) return
val focusManager = LocalFocusManager.current
var input by rememberSaveable(mode, initialInput) { mutableStateOf(initialInput) }
val profileNames = remember(profiles) { profiles.map { it.name } }
val profileIds = remember(profiles) { profiles.map { it.id } }
val copySourceItems = remember(profileNames) { listOf("默认") + profileNames }
var copySourceProfileId by rememberSaveable(mode, initialCopySourceProfileId) {
mutableStateOf(initialCopySourceProfileId)
}
val copySourceDropdownIndex = remember(copySourceProfileId, profileIds) {
copySourceProfileId
?.let { profileIds.indexOf(it).takeIf { index -> index >= 0 }?.plus(1) }
?: 0
}
OverlayDialog(
show = true,
title = when (mode) {
ProfileDialogMode.Create -> "新建配置"
ProfileDialogMode.Rename -> "重命名配置"
},
summary = "重复时会自动追加序号",
summary = "配置名重复时会自动追加序号",
onDismissRequest = onDismissRequest,
) {
Column(
@@ -1952,7 +1975,7 @@ private fun ProfileNameDialog(
TextField(
value = input,
onValueChange = { input = it },
label = "配置名",
label = "配置名",
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
@@ -1960,19 +1983,35 @@ private fun ProfileNameDialog(
),
modifier = Modifier.fillMaxWidth(),
)
AnimatedVisibility(mode == ProfileDialogMode.Create) {
OverlayDropdownPreference(
title = "复制配置",
items = copySourceItems,
selectedIndex = copySourceDropdownIndex,
onSelectedIndexChange = { index ->
copySourceProfileId = if (index == 0) {
null
} else {
profileIds.getOrElse(index - 1) {
ScrcpyOptions.GLOBAL_PROFILE_ID
}
}
},
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
) {
top.yukonga.miuix.kmp.basic.TextButton(
TextButton(
text = "取消",
onClick = onDismissRequest,
modifier = Modifier.weight(1f),
)
top.yukonga.miuix.kmp.basic.TextButton(
TextButton(
text = "确定",
onClick = { onConfirm(input) },
onClick = { onConfirm(input, copySourceProfileId) },
modifier = Modifier.weight(1f),
colors = top.yukonga.miuix.kmp.basic.ButtonDefaults.textButtonColorsPrimary(),
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}
@@ -1982,7 +2021,7 @@ private fun ProfileNameDialog(
@Composable
private fun ManageProfilesSheet(
show: Boolean,
profiles: List<io.github.miuzarte.scrcpyforandroid.storage.ScrcpyProfiles.Profile>,
profiles: List<ScrcpyProfiles.Profile>,
selectedProfileId: String,
onDismissRequest: () -> Unit,
onCreateProfile: () -> Unit,
@@ -2073,7 +2112,7 @@ private fun DeleteProfileDialog(
text = "删除",
onClick = onConfirm,
modifier = Modifier.weight(1f),
colors = top.yukonga.miuix.kmp.basic.ButtonDefaults.textButtonColorsPrimary(),
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}

View File

@@ -125,43 +125,42 @@ private fun SliderInputDialog(
summary = summary,
onDismissRequest = onDismissRequest,
onDismissFinished = onDismissFinished,
content = {
var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) }
) {
var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) }
SuperTextField(
modifier = Modifier.padding(bottom = 16.dp),
value = text,
label = label,
useLabelAsPlaceholder = useLabelAsPlaceholder,
maxLines = 1,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
onValueChange = { newValue ->
text = inputFilter(newValue)
},
SuperTextField(
modifier = Modifier.padding(bottom = 16.dp),
value = text,
label = label,
useLabelAsPlaceholder = useLabelAsPlaceholder,
maxLines = 1,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
onValueChange = { newValue ->
text = inputFilter(newValue)
},
)
Row(horizontalArrangement = Arrangement.SpaceBetween) {
TextButton(
text = "取消",
onClick = onDismissRequest,
modifier = Modifier.weight(1f),
)
Row(horizontalArrangement = Arrangement.SpaceBetween) {
TextButton(
text = "取消",
onClick = onDismissRequest,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(20.dp))
TextButton(
text = "确定",
onClick = {
val inputValue = text.toFloatOrNull() ?: 0f
if (inputValue >= inputValueRange.start && inputValue <= inputValueRange.endInclusive) {
onConfirm(text.trim())
}
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
},
)
Spacer(Modifier.width(20.dp))
TextButton(
text = "确定",
onClick = {
val inputValue = text.toFloatOrNull() ?: 0f
if (inputValue >= inputValueRange.start && inputValue <= inputValueRange.endInclusive) {
onConfirm(text.trim())
}
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}
}

View File

@@ -0,0 +1,146 @@
package io.github.miuzarte.scrcpyforandroid.services
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.Closeable
import java.util.concurrent.Executors
import kotlinx.coroutines.asCoroutineDispatcher
internal class DeviceAdbBackgroundRunner : Closeable {
private val executor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "device-adb-monitor").apply { isDaemon = true }
}
private val dispatcher: ExecutorCoroutineDispatcher = executor.asCoroutineDispatcher()
suspend fun runKeepAliveLoop(
sessionState: () -> DeviceAdbSessionState,
isForeground: () -> Boolean,
intervalMs: Long,
keepAliveCheck: suspend (host: String, port: Int) -> Boolean,
reconnect: suspend (host: String, port: Int) -> Unit,
onReconnectSuccess: suspend (host: String, port: Int) -> Unit,
onReconnectFailure: suspend (Throwable) -> Unit,
) = withContext(dispatcher) {
val target = sessionState().currentTarget ?: return@withContext
val host = target.host
val port = target.port
while (sessionState().isConnected && sessionState().currentTarget == target) {
if (!isForeground()) {
delay(intervalMs)
continue
}
delay(intervalMs)
val alive = runCatching {
keepAliveCheck(host, port)
}.getOrElse { false }
if (alive) continue
try {
reconnect(host, port)
withContext(Dispatchers.Main) {
onReconnectSuccess(host, port)
}
} catch (error: Exception) {
withContext(Dispatchers.Main) {
onReconnectFailure(error)
}
break
}
}
}
suspend fun runAutoReconnectLoop(
isConnected: () -> Boolean,
isForeground: () -> Boolean,
isAutoReconnectEnabled: () -> Boolean,
isBusy: () -> Boolean,
isAdbConnecting: () -> Boolean,
hasActiveSession: () -> Boolean,
savedShortcuts: () -> List<DeviceShortcut>,
isBlacklisted: (String) -> Boolean,
probeTcpReachable: suspend (host: String, port: Int) -> Boolean,
discoverConnectService: suspend () -> Pair<String, Int>?,
onMdnsPortChanged: suspend (host: String, oldPort: Int, newPort: Int) -> Unit,
connectKnownShortcut: suspend (shortcut: DeviceShortcut) -> Boolean,
connectDiscoveredShortcut: suspend (
host: String,
port: Int,
shortcut: DeviceShortcut,
) -> Boolean,
retryIntervalMs: Long,
) = withContext(dispatcher) {
val quickConnectTriedOnce = mutableSetOf<String>()
while (!isConnected() && isAutoReconnectEnabled()) {
if (!isForeground() || isBusy() || isAdbConnecting() || hasActiveSession()) {
delay(retryIntervalMs)
continue
}
val quickCandidates = savedShortcuts()
if (quickCandidates.isNotEmpty()) {
for (target in quickCandidates) {
if (isConnected() || isAdbConnecting()) break
if (isBlacklisted(target.host)) continue
val targetKey = "${target.host}:${target.port}"
if (quickConnectTriedOnce.contains(targetKey)) continue
if (!probeTcpReachable(target.host, target.port)) continue
quickConnectTriedOnce += targetKey
if (connectKnownShortcut(target)) {
break
}
}
if (isConnected()) break
}
val discovered = discoverConnectService()
if (discovered == null) {
delay(retryIntervalMs)
continue
}
val (discoveredHost, discoveredPort) = discovered
if (isBlacklisted(discoveredHost)) {
delay(retryIntervalMs)
continue
}
val knownDevice = savedShortcuts().firstOrNull { it.host == discoveredHost }
if (knownDevice == null) {
delay(retryIntervalMs)
continue
}
val portToReplace = savedShortcuts().firstOrNull {
it.host == discoveredHost &&
it.port != knownDevice.port &&
it.port != discoveredPort
}?.port
if (portToReplace != null) {
withContext(Dispatchers.Main) {
onMdnsPortChanged(discoveredHost, portToReplace, discoveredPort)
}
}
if (isConnected() || isAdbConnecting()) {
delay(retryIntervalMs)
continue
}
connectDiscoveredShortcut(discoveredHost, discoveredPort, knownDevice)
delay(retryIntervalMs)
}
}
override fun close() {
dispatcher.close()
executor.shutdownNow()
}
}

View File

@@ -0,0 +1,95 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.os.Parcelable
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.parcelize.Parcelize
import java.net.InetSocketAddress
import java.net.Socket
@Parcelize
internal data class DeviceAdbSessionState(
val isConnected: Boolean = false,
val statusLine: String = "未连接",
val currentTarget: ConnectionTarget? = null,
val connectedDeviceLabel: String = "未连接",
val isQuickConnected: Boolean = false,
val connectedScrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
val audioForwardingSupported: Boolean = true,
val cameraMirroringSupported: Boolean = true,
) : Parcelable
internal class DeviceAdbConnectionCoordinator(
private val adbService: NativeAdbService = NativeAdbService,
) {
suspend fun connectWithTimeout(host: String, port: Int, timeoutMs: Long) {
withTimeout(timeoutMs) {
adbService.connect(host, port)
}
}
suspend fun disconnect() {
adbService.disconnect()
}
suspend fun isConnected(timeoutMs: Long): Boolean {
return withTimeout(timeoutMs) {
adbService.isConnected()
}
}
suspend fun probeTcpReachable(host: String, port: Int, timeoutMs: Int): Boolean {
return withContext(Dispatchers.IO) {
runCatching {
Socket().use { socket ->
socket.connect(InetSocketAddress(host, port), timeoutMs)
true
}
}.getOrDefault(false)
}
}
suspend fun fetchConnectedDeviceInfo(host: String, port: Int): ConnectedDeviceInfo {
return fetchConnectedDeviceInfo(adbService, host, port)
}
suspend fun discoverPairingService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true,
): Pair<String, Int>? {
return adbService.discoverPairingService(
timeoutMs = timeoutMs,
includeLanDevices = includeLanDevices,
)
}
suspend fun discoverConnectService(
timeoutMs: Long = 12_000,
includeLanDevices: Boolean = true,
): Pair<String, Int>? {
return adbService.discoverConnectService(
timeoutMs = timeoutMs,
includeLanDevices = includeLanDevices,
)
}
suspend fun pair(host: String, port: Int, pairingCode: String): Boolean {
return adbService.pair(host, port, pairingCode)
}
suspend fun startApp(
packageName: String,
displayId: Int? = null,
forceStop: Boolean = false,
): String {
return adbService.startApp(
packageName = packageName,
displayId = displayId,
forceStop = forceStop,
)
}
}

View File

@@ -239,11 +239,10 @@ internal fun PairingCard(
onDiscoverTarget = onDiscoverTarget,
onDismissRequest = { showPairDialog.value = false },
onDismissFinished = { holdDownState.value = false },
onConfirm = { host, port, code ->
showPairDialog.value = false
onPair(host, port, code)
},
)
) { host, port, code ->
showPairDialog.value = false
onPair(host, port, code)
}
}
@Composable
@@ -673,95 +672,94 @@ private fun PairingDialog(
onDismissFinished = {
onDismissFinished()
},
content = {
Column(
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
TextField(
value = host,
onValueChange = { host = it },
label = "IP 地址",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.fillMaxWidth()
)
TextField(
value = port,
onValueChange = { port = it.filter(Char::isDigit) },
label = "端口",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.fillMaxWidth()
)
TextField(
value = code,
onValueChange = { code = it },
label = "WLAN 配对码",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() },
),
modifier = Modifier.fillMaxWidth()
)
}
) {
Column(
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
TextField(
value = host,
onValueChange = { host = it },
label = "IP 地址",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.fillMaxWidth()
)
TextField(
value = port,
onValueChange = { port = it.filter(Char::isDigit) },
label = "端口",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.fillMaxWidth()
)
TextField(
value = code,
onValueChange = { code = it },
label = "WLAN 配对码",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() },
),
modifier = Modifier.fillMaxWidth()
)
}
Spacer(Modifier.height(UiSpacing.ContentVertical * 2))
Spacer(Modifier.height(UiSpacing.ContentVertical * 2))
Column(
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
Column(
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
TextButton(
text = if (!discoveringPort) "自动发现" else "发现中...",
onClick = {
if (enabled && onDiscoverTarget != null && !discoveringPort)
scope.launch { doDiscover() }
},
enabled = enabled && onDiscoverTarget != null && !discoveringPort,
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
) {
TextButton(
text = if (!discoveringPort) "自动发现" else "发现中...",
text = "取消",
onClick = {
if (enabled && onDiscoverTarget != null && !discoveringPort)
scope.launch { doDiscover() }
onDismissRequest()
},
enabled = enabled && onDiscoverTarget != null && !discoveringPort,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.weight(1f),
)
TextButton(
text = "配对",
onClick = {
onConfirm(host.trim(), port.trim(), code.trim())
onDismissRequest()
},
enabled = enabled &&
host.trim().isNotBlank() &&
port.trim().isNotBlank() &&
code.trim().isNotBlank(),
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(),
)
Row(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
) {
TextButton(
text = "取消",
onClick = {
onDismissRequest()
},
modifier = Modifier.weight(1f),
)
TextButton(
text = "配对",
onClick = {
onConfirm(host.trim(), port.trim(), code.trim())
onDismissRequest()
},
enabled = enabled &&
host.trim().isNotBlank() &&
port.trim().isNotBlank() &&
code.trim().isNotBlank(),
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}
},
)
}
}
}
/**
@@ -1055,7 +1053,7 @@ internal fun DeviceTile(
SuperTextField(
value = currentDraft.name,
onValueChange = { draft = currentDraft.copy(name = it) },
label = "设备名",
label = "设备名",
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -1101,7 +1099,7 @@ internal fun DeviceTile(
)
}
OverlayDropdownPreference(
title = "Scrcpy 配置",
title = "scrcpy 配置",
items = profileNames,
selectedIndex = profileDropdownIndex,
onSelectedIndexChange = {