improve: multi profiles management
- 在 ScrcpyAllOptionsScreen 中添加配置管理功能,包括创建、重命名和删除配置 - 实现配置的标签页导航,并在切换配置时显示 snack - 重构 PairingDialog 和 DeviceWidgets - 引入 DeviceAdbBackgroundRunner 用于后台管理 ADB 连接 - 创建 DeviceAdbConnectionCoordinator 处理设备连接逻辑和状态管理
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,37 +751,41 @@ 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
|
||||
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,
|
||||
)
|
||||
},
|
||||
onMdnsPortChanged = { host, oldPort, newPort ->
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = target.host, port = target.port,
|
||||
host = host,
|
||||
port = oldPort,
|
||||
newPort = newPort,
|
||||
)
|
||||
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,
|
||||
@@ -782,81 +793,84 @@ fun DeviceTabPage(
|
||||
scrcpyProfileId = target.scrcpyProfileId,
|
||||
)
|
||||
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
|
||||
} catch (_: Exception) {
|
||||
true
|
||||
}
|
||||
break
|
||||
}
|
||||
if (adbConnected) break
|
||||
}
|
||||
|
||||
val discovered = withContext(Dispatchers.IO) {
|
||||
NativeAdbService.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) {
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = discoveredHost, port = portToReplace,
|
||||
newPort = discoveredPort,
|
||||
)
|
||||
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,
|
||||
)
|
||||
},
|
||||
connectDiscoveredShortcut = { host, port, knownDevice ->
|
||||
if (!runAutoAdbConnect(host, port)) {
|
||||
false
|
||||
} else {
|
||||
savedShortcuts = savedShortcuts.update(host = host, port = port)
|
||||
handleAdbConnected(
|
||||
discoveredHost,
|
||||
discoveredPort,
|
||||
host,
|
||||
port,
|
||||
scrcpyProfileId = knownDevice.scrcpyProfileId,
|
||||
)
|
||||
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
|
||||
} catch (_: Exception) {
|
||||
logEvent("ADB 自动重连成功: $host:$port")
|
||||
true
|
||||
}
|
||||
},
|
||||
retryIntervalMs = ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS,
|
||||
)
|
||||
}
|
||||
|
||||
delay(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()
|
||||
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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,32 +279,40 @@ 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 ->
|
||||
) { 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
|
||||
}
|
||||
val created = scrcpyProfiles.createProfile(
|
||||
requestedName = input,
|
||||
bundle = soBundleState.value,
|
||||
bundle = copySourceBundle,
|
||||
)
|
||||
selectedProfileId = created.id
|
||||
}
|
||||
@@ -266,10 +328,10 @@ internal fun ScrcpyAllOptionsScreen(
|
||||
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,7 +367,7 @@ 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)
|
||||
@@ -316,8 +379,7 @@ internal fun ScrcpyAllOptionsScreen(
|
||||
}
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ private fun SliderInputDialog(
|
||||
summary = summary,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onDismissFinished = onDismissFinished,
|
||||
content = {
|
||||
) {
|
||||
var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) }
|
||||
|
||||
SuperTextField(
|
||||
@@ -162,6 +162,5 @@ private fun SliderInputDialog(
|
||||
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -239,11 +239,10 @@ internal fun PairingCard(
|
||||
onDiscoverTarget = onDiscoverTarget,
|
||||
onDismissRequest = { showPairDialog.value = false },
|
||||
onDismissFinished = { holdDownState.value = false },
|
||||
onConfirm = { host, port, code ->
|
||||
) { host, port, code ->
|
||||
showPairDialog.value = false
|
||||
onPair(host, port, code)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -673,7 +672,7 @@ private fun PairingDialog(
|
||||
onDismissFinished = {
|
||||
onDismissFinished()
|
||||
},
|
||||
content = {
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
||||
) {
|
||||
@@ -760,8 +759,7 @@ private fun PairingDialog(
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user