From 17ae903354ab19bf5fd370134262c3a3ae772f25 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Sun, 12 Apr 2026 17:31:38 +0800 Subject: [PATCH] improve: multi profiles management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 ScrcpyAllOptionsScreen 中添加配置管理功能,包括创建、重命名和删除配置 - 实现配置的标签页导航,并在切换配置时显示 snack - 重构 PairingDialog 和 DeviceWidgets - 引入 DeviceAdbBackgroundRunner 用于后台管理 ADB 连接 - 创建 DeviceAdbConnectionCoordinator 处理设备连接逻辑和状态管理 --- .../scrcpyforandroid/models/DeviceModels.kt | 5 +- .../pages/DeviceTabAdbCallbacks.kt | 136 +++++ .../scrcpyforandroid/pages/DeviceTabScreen.kt | 524 ++++++++---------- .../pages/FullscreenControlScreen.kt | 3 +- .../pages/ScrcpyAllOptionsScreen.kt | 249 +++++---- .../scrcpyforandroid/scaffolds/SuperSlider.kt | 73 ++- .../services/DeviceAdbBackgroundRunner.kt | 146 +++++ .../DeviceAdbConnectionCoordinator.kt | 95 ++++ .../scrcpyforandroid/widgets/DeviceWidgets.kt | 174 +++--- 9 files changed, 869 insertions(+), 536 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabAdbCallbacks.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index 0d10a5d..f70d8ce 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -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) : List 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 { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabAdbCallbacks.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabAdbCallbacks.kt new file mode 100644 index 0000000..051dbf9 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabAdbCallbacks.kt @@ -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?, + private val pairTarget: suspend (host: String, port: Int, code: String) -> Boolean, + private val isConnectedToTarget: (host: String, port: Int) -> Boolean, + private val onConnectionFailed: (Throwable) -> Unit, + private val onQuickConnectedChanged: (Boolean) -> Unit, + private val onBlackListHost: (String) -> Unit, + private val setActiveDeviceActionId: (String?) -> Unit, +) { + fun onDeviceAction(device: DeviceShortcut) { + val host = device.host + val port = device.port + val connected = isConnectedToTarget(host, port) + + if (!connected) { + runAdbConnect( + "连接 ADB", + { setActiveDeviceActionId(device.id) }, + { setActiveDeviceActionId(null) }, + ) { + disconnectCurrentTargetBeforeConnecting(host, port) + try { + connectWithTimeout(host, port) + handleAdbConnected( + host, + port, + device.startScrcpyOnConnect, + device.startScrcpyOnConnect && device.openFullscreenOnStart, + device.scrcpyProfileId, + ) + onQuickConnectedChanged(false) + } catch (error: Exception) { + onConnectionFailed(error) + } + } + return + } + + runAdbConnect( + "断开 ADB", + { setActiveDeviceActionId(device.id) }, + { setActiveDeviceActionId(null) }, + ) { + onBlackListHost(host) + disconnectAdbConnection( + ConnectionTarget(host, port), + "ADB 已断开: ${device.name}", + "ADB 已断开", + ) + } + } + + fun onQuickConnect(target: ConnectionTarget) { + runAdbConnect( + "连接 ADB", + { setActiveDeviceActionId(target.toString()) }, + { setActiveDeviceActionId(null) }, + ) { + disconnectCurrentTargetBeforeConnecting(target.host, target.port) + try { + connectWithTimeout(target.host, target.port) + handleAdbConnected( + target.host, + target.port, + false, + false, + ScrcpyOptions.GLOBAL_PROFILE_ID, + ) + onQuickConnectedChanged(true) + } catch (error: Exception) { + onConnectionFailed(error) + } + } + } + + fun onDisconnectCurrent(target: ConnectionTarget?) { + runAdbConnect( + "断开 ADB", + null, + null, + ) { + target?.let { + onBlackListHost(it.host) + disconnectAdbConnection( + it, + "ADB 已断开", + "ADB 已断开", + ) + } + } + } + + fun onPair(host: String, port: String, code: String) { + runBusy("执行配对", null) { + val resolvedHost = host.trim() + val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy + val resolvedCode = code.trim() + pairTarget(resolvedHost, resolvedPort, resolvedCode) + } + } + + suspend fun onDiscoverPairingTarget(): Pair? { + return discoverPairingTarget() + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index cabe3d4..977741b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -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(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf(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() - 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) + } + }, ) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 6c67e4f..f5dc883 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -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 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt index c3cc2f9..598ae64 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -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(null) } var profileDialogTargetId by rememberSaveable { mutableStateOf(null) } - var profileDialogInput by rememberSaveable { mutableStateOf("新配置") } + var profileDialogInput by rememberSaveable { mutableStateOf("") } + var profileDialogCopySourceId by rememberSaveable { mutableStateOf(null) } var deletingProfileId by rememberSaveable { mutableStateOf(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, soBundleState: MutableState, lastValidSoBundleState: MutableState, - 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, + 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, + profiles: List, 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(), ) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt index 173a428..9cb6f83 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt @@ -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(), + ) + } + } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt new file mode 100644 index 0000000..5756e70 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt @@ -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, + isBlacklisted: (String) -> Boolean, + probeTcpReachable: suspend (host: String, port: Int) -> Boolean, + discoverConnectService: suspend () -> Pair?, + 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() + 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() + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt new file mode 100644 index 0000000..b9f4e90 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt @@ -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? { + return adbService.discoverPairingService( + timeoutMs = timeoutMs, + includeLanDevices = includeLanDevices, + ) + } + + suspend fun discoverConnectService( + timeoutMs: Long = 12_000, + includeLanDevices: Boolean = true, + ): Pair? { + 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, + ) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index c4abcdd..e6623ee 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -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 = {