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
|
package io.github.miuzarte.scrcpyforandroid.models
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
// Composable 用, 不可变 List
|
// Composable 用, 不可变 List
|
||||||
class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut> by devices {
|
class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut> by devices {
|
||||||
@@ -182,10 +184,11 @@ data class DeviceShortcut(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
data class ConnectionTarget(
|
data class ConnectionTarget(
|
||||||
val host: String,
|
val host: String,
|
||||||
val port: Int = Defaults.ADB_PORT,
|
val port: Int = Defaults.ADB_PORT,
|
||||||
) {
|
) : Parcelable {
|
||||||
override fun toString(): String = "$host:$port"
|
override fun toString(): String = "$host:$port"
|
||||||
|
|
||||||
companion object {
|
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.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -26,6 +25,9 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.StreamActivity
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
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.ConnectionTarget
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
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.scaffolds.LazyColumn
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks
|
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
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
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.ScrcpyOptions
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
@@ -66,7 +70,6 @@ import kotlinx.coroutines.TimeoutCancellationException
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import top.yukonga.miuix.kmp.basic.Card
|
import top.yukonga.miuix.kmp.basic.Card
|
||||||
import top.yukonga.miuix.kmp.basic.DropdownImpl
|
import top.yukonga.miuix.kmp.basic.DropdownImpl
|
||||||
import top.yukonga.miuix.kmp.basic.Icon
|
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.basic.TopAppBar
|
||||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.net.Socket
|
|
||||||
|
|
||||||
private const val ADB_CONNECT_TIMEOUT_MS = 12_000L
|
private const val ADB_CONNECT_TIMEOUT_MS = 12_000L
|
||||||
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L
|
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L
|
||||||
@@ -163,6 +164,9 @@ fun DeviceTabPage(
|
|||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
|
val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
|
||||||
|
val adbCoordinator = remember { DeviceAdbConnectionCoordinator() }
|
||||||
|
val adbBackgroundRunner = remember { DeviceAdbBackgroundRunner() }
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
val haptics = LocalAppHaptics.current
|
val haptics = LocalAppHaptics.current
|
||||||
val navigator = LocalRootNavigator.current
|
val navigator = LocalRootNavigator.current
|
||||||
@@ -218,32 +222,43 @@ fun DeviceTabPage(
|
|||||||
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
|
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
|
||||||
val scrcpyProfilesState by scrcpyProfiles.state.collectAsState()
|
val scrcpyProfilesState by scrcpyProfiles.state.collectAsState()
|
||||||
|
|
||||||
|
var isAppInForeground by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
AppWakeLocks.release()
|
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.
|
// Run adb operations on a dedicated single thread.
|
||||||
// Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic.
|
// Try to avoid blocking UI/recomposition and keeps adb call ordering deterministic.
|
||||||
|
|
||||||
var busy by rememberSaveable { mutableStateOf(false) }
|
var busy by rememberSaveable { mutableStateOf(false) }
|
||||||
var statusLine by rememberSaveable { mutableStateOf("未连接") }
|
var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) }
|
||||||
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("未连接") }
|
|
||||||
val sessionInfo by scrcpy.currentSessionState.collectAsState()
|
val sessionInfo by scrcpy.currentSessionState.collectAsState()
|
||||||
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
|
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
|
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var adbConnecting by rememberSaveable { mutableStateOf(false) }
|
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) }
|
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val isPreviewCardVisible by remember(listState) {
|
val isPreviewCardVisible by remember(listState) {
|
||||||
@@ -252,10 +267,14 @@ fun DeviceTabPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentTarget =
|
val adbConnected = adbSession.isConnected
|
||||||
if (currentTargetHost.isNotBlank())
|
val statusLine = adbSession.statusLine
|
||||||
ConnectionTarget(currentTargetHost, currentTargetPort)
|
val isQuickConnected = adbSession.isQuickConnected
|
||||||
else null
|
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 {
|
fun resolveScrcpyBundle(profileId: String): ScrcpyOptions.Bundle {
|
||||||
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||||
@@ -317,17 +336,10 @@ fun DeviceTabPage(
|
|||||||
) {
|
) {
|
||||||
// Also stops scrcpy.
|
// Also stops scrcpy.
|
||||||
runCatching { scrcpy.stop() }
|
runCatching { scrcpy.stop() }
|
||||||
runCatching { NativeAdbService.disconnect() }
|
runCatching { adbCoordinator.disconnect() }
|
||||||
AppWakeLocks.release()
|
AppWakeLocks.release()
|
||||||
adbConnected = false
|
adbSession = DeviceAdbSessionState()
|
||||||
currentTargetHost = ""
|
|
||||||
currentTargetPort = Defaults.ADB_PORT
|
|
||||||
connectedScrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID
|
|
||||||
AppRuntime.currentConnectionTarget = null
|
AppRuntime.currentConnectionTarget = null
|
||||||
audioForwardingSupported = true
|
|
||||||
cameraMirroringSupported = true
|
|
||||||
statusLine = "未连接"
|
|
||||||
connectedDeviceLabel = "未连接"
|
|
||||||
clearQuickOnlineForTarget?.let { target ->
|
clearQuickOnlineForTarget?.let { target ->
|
||||||
if (target.host.isNotBlank())
|
if (target.host.isNotBlank())
|
||||||
savedShortcuts = savedShortcuts.update(
|
savedShortcuts = savedShortcuts.update(
|
||||||
@@ -353,7 +365,7 @@ fun DeviceTabPage(
|
|||||||
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
|
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
|
||||||
val currentBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
val currentBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
||||||
val audioSupported = sdkInt !in 0..<30
|
val audioSupported = sdkInt !in 0..<30
|
||||||
audioForwardingSupported = audioSupported
|
adbSession = adbSession.copy(audioForwardingSupported = audioSupported)
|
||||||
if (!audioSupported && currentBundle.audio) {
|
if (!audioSupported && currentBundle.audio) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||||
@@ -371,7 +383,7 @@ fun DeviceTabPage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val cameraSupported = sdkInt !in 0..<31
|
val cameraSupported = sdkInt !in 0..<31
|
||||||
cameraMirroringSupported = cameraSupported
|
adbSession = adbSession.copy(cameraMirroringSupported = cameraSupported)
|
||||||
if (!cameraSupported && currentBundle.videoSource == "camera") {
|
if (!cameraSupported && currentBundle.videoSource == "camera") {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||||
@@ -403,9 +415,7 @@ fun DeviceTabPage(
|
|||||||
* indefinitely. Use a small, caller-chosen timeout to keep UX snappy.
|
* indefinitely. Use a small, caller-chosen timeout to keep UX snappy.
|
||||||
*/
|
*/
|
||||||
suspend fun connectWithTimeout(host: String, port: Int) {
|
suspend fun connectWithTimeout(host: String, port: Int) {
|
||||||
return withTimeout(ADB_CONNECT_TIMEOUT_MS) {
|
adbCoordinator.connectWithTimeout(host, port, ADB_CONNECT_TIMEOUT_MS)
|
||||||
NativeAdbService.connect(host, port)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -422,10 +432,7 @@ fun DeviceTabPage(
|
|||||||
* echo check helps detect that state.
|
* echo check helps detect that state.
|
||||||
*/
|
*/
|
||||||
suspend fun keepAliveCheck(host: String, port: Int): Boolean {
|
suspend fun keepAliveCheck(host: String, port: Int): Boolean {
|
||||||
return withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
|
return adbCoordinator.isConnected(ADB_KEEPALIVE_TIMEOUT_MS)
|
||||||
val connected = NativeAdbService.isConnected()
|
|
||||||
return@withTimeout connected
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -436,14 +443,7 @@ fun DeviceTabPage(
|
|||||||
* - Returns true when TCP handshake succeeds within [ADB_TCP_PROBE_TIMEOUT_MS].
|
* - Returns true when TCP handshake succeeds within [ADB_TCP_PROBE_TIMEOUT_MS].
|
||||||
*/
|
*/
|
||||||
suspend fun probeTcpReachable(host: String, port: Int): Boolean {
|
suspend fun probeTcpReachable(host: String, port: Int): Boolean {
|
||||||
return withContext(Dispatchers.IO) {
|
return adbCoordinator.probeTcpReachable(host, port, ADB_TCP_PROBE_TIMEOUT_MS)
|
||||||
runCatching {
|
|
||||||
Socket().use { socket ->
|
|
||||||
socket.connect(InetSocketAddress(host, port), ADB_TCP_PROBE_TIMEOUT_MS)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -519,47 +519,45 @@ fun DeviceTabPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun runAutoAdbConnect(host: String, port: Int) {
|
suspend fun runAutoAdbConnect(host: String, port: Int): Boolean {
|
||||||
runCatching {
|
return runCatching {
|
||||||
connectWithTimeout(host, port)
|
connectWithTimeout(host, port)
|
||||||
|
true
|
||||||
}.getOrElse { error ->
|
}.getOrElse { error ->
|
||||||
val detail = error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
|
val detail = error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
|
||||||
logEvent("自动重连失败: $host:$port ($detail)", Log.WARN)
|
logEvent("自动重连失败: $host:$port ($detail)", Log.WARN)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) {
|
LaunchedEffect(adbConnected, currentTarget, isAppInForeground) {
|
||||||
if (!adbConnected || currentTargetHost.isBlank()) return@LaunchedEffect
|
if (!adbConnected || currentTarget == null) return@LaunchedEffect
|
||||||
|
adbBackgroundRunner.runKeepAliveLoop(
|
||||||
// Keep-alive loop for current target.
|
sessionState = { adbSession },
|
||||||
// On failure: try to reconnect once; if failed, fully disconnect and reset UI state.
|
isForeground = { isAppInForeground },
|
||||||
val host = currentTargetHost
|
intervalMs = ADB_KEEPALIVE_INTERVAL_MS,
|
||||||
val port = currentTargetPort
|
keepAliveCheck = ::keepAliveCheck,
|
||||||
while (adbConnected && currentTargetHost == host && currentTargetPort == port) {
|
reconnect = ::connectWithTimeout,
|
||||||
delay(ADB_KEEPALIVE_INTERVAL_MS)
|
onReconnectSuccess = { host, port ->
|
||||||
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"
|
|
||||||
logEvent("ADB 自动重连成功: $host:$port")
|
logEvent("ADB 自动重连成功: $host:$port")
|
||||||
|
adbSession = adbSession.copy(
|
||||||
|
isConnected = true,
|
||||||
|
statusLine = "$host:$port",
|
||||||
|
)
|
||||||
snackbar.show("ADB 自动重连成功")
|
snackbar.show("ADB 自动重连成功")
|
||||||
} catch (e: Exception) {
|
},
|
||||||
|
onReconnectFailure = { error ->
|
||||||
disconnectAdbConnection()
|
disconnectAdbConnection()
|
||||||
statusLine = "ADB 连接断开"
|
adbSession = adbSession.copy(statusLine = "ADB 连接断开")
|
||||||
logEvent("ADB 自动重连失败: $e", Log.ERROR)
|
logEvent("ADB 自动重连失败: $error", Log.ERROR)
|
||||||
snackbar.show("ADB 自动重连失败")
|
snackbar.show("ADB 自动重连失败")
|
||||||
break
|
},
|
||||||
}
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resolveStartAppRequest(
|
suspend fun resolveStartAppRequest(
|
||||||
scrcpy: Scrcpy,
|
scrcpy: Scrcpy,
|
||||||
options: io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions,
|
options: ClientOptions,
|
||||||
): StartAppRequest? {
|
): StartAppRequest? {
|
||||||
val raw = options.startApp.trim()
|
val raw = options.startApp.trim()
|
||||||
if (raw.isBlank()) {
|
if (raw.isBlank()) {
|
||||||
@@ -588,14 +586,20 @@ fun DeviceTabPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val searchName = query.drop(1).trim()
|
val searchName = query.drop(1).trim()
|
||||||
require(searchName.isNotBlank()) { "应用名称不能为空" }
|
require(searchName.isNotBlank()) {
|
||||||
|
"应用名不能为空"
|
||||||
|
}
|
||||||
|
|
||||||
val apps = scrcpy.listings.getApps(forceRefresh = false)
|
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) {
|
require(matches.size == 1) {
|
||||||
"按名称匹配到多个应用: " +
|
"按应用名匹配到多个应用: " +
|
||||||
matches.take(5).joinToString { "${it.label} (${it.packageName})" }
|
matches.take(5).joinToString { "${it.label} (${it.packageName})" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,7 +636,7 @@ fun DeviceTabPage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
runCatching {
|
runCatching {
|
||||||
NativeAdbService.startApp(
|
adbCoordinator.startApp(
|
||||||
packageName = request.packageName,
|
packageName = request.packageName,
|
||||||
displayId = request.displayId,
|
displayId = request.displayId,
|
||||||
forceStop = request.forceStop,
|
forceStop = request.forceStop,
|
||||||
@@ -659,7 +663,7 @@ fun DeviceTabPage(
|
|||||||
if (options.disableScreensaver)
|
if (options.disableScreensaver)
|
||||||
AppWakeLocks.acquire()
|
AppWakeLocks.acquire()
|
||||||
|
|
||||||
statusLine = "scrcpy 运行中"
|
adbSession = adbSession.copy(statusLine = "scrcpy 运行中")
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
val videoDetail =
|
val videoDetail =
|
||||||
if (!options.video) "off"
|
if (!options.video) "off"
|
||||||
@@ -703,26 +707,29 @@ fun DeviceTabPage(
|
|||||||
autoEnterFullScreen: Boolean = false,
|
autoEnterFullScreen: Boolean = false,
|
||||||
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||||
) {
|
) {
|
||||||
currentTargetHost = host
|
val target = ConnectionTarget(host, port)
|
||||||
currentTargetPort = port
|
adbSession = adbSession.copy(
|
||||||
connectedScrcpyProfileId = scrcpyProfileId
|
isConnected = true,
|
||||||
AppRuntime.currentConnectionTarget = ConnectionTarget(host, port)
|
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()) {
|
val fullLabel = if (info.serial.isNotBlank()) {
|
||||||
"${info.model} (${info.serial})"
|
"${info.model} (${info.serial})"
|
||||||
} else {
|
} else {
|
||||||
info.model
|
info.model
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedDeviceLabel = info.model
|
adbSession = adbSession.copy(connectedDeviceLabel = info.model)
|
||||||
applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease)
|
applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease)
|
||||||
savedShortcuts = savedShortcuts.update(
|
savedShortcuts = savedShortcuts.update(
|
||||||
host = host, port = port,
|
host = host, port = port,
|
||||||
name = fullLabel,
|
name = fullLabel,
|
||||||
updateNameOnlyWhenEmpty = true
|
updateNameOnlyWhenEmpty = true
|
||||||
)
|
)
|
||||||
statusLine = "$host:$port"
|
|
||||||
|
|
||||||
logEvent(
|
logEvent(
|
||||||
"ADB 已连接: " +
|
"ADB 已连接: " +
|
||||||
@@ -744,119 +751,126 @@ fun DeviceTabPage(
|
|||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
adbConnected,
|
adbConnected,
|
||||||
asBundle.adbAutoReconnectPairedDevice,
|
asBundle.adbAutoReconnectPairedDevice,
|
||||||
asBundle.adbMdnsLanDiscovery
|
asBundle.adbMdnsLanDiscovery,
|
||||||
|
isAppInForeground,
|
||||||
) {
|
) {
|
||||||
if (adbConnected || !asBundle.adbAutoReconnectPairedDevice) return@LaunchedEffect
|
if (adbConnected || !asBundle.adbAutoReconnectPairedDevice) return@LaunchedEffect
|
||||||
|
adbBackgroundRunner.runAutoReconnectLoop(
|
||||||
// Background auto reconnect pipeline:
|
isConnected = { adbConnected },
|
||||||
// 1) try quick list targets with reachable TCP ports
|
isForeground = { isAppInForeground },
|
||||||
// 2) fallback to mDNS discovery
|
isAutoReconnectEnabled = { asBundle.adbAutoReconnectPairedDevice },
|
||||||
val quickConnectTriedOnce = mutableSetOf<String>()
|
isBusy = { busy },
|
||||||
while (!adbConnected) {
|
isAdbConnecting = { adbConnecting },
|
||||||
if (busy || adbConnecting || sessionInfo != null) {
|
hasActiveSession = { sessionInfo != null },
|
||||||
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
savedShortcuts = { savedShortcuts.toList() },
|
||||||
continue
|
isBlacklisted = { host -> sessionReconnectBlacklistHosts.contains(host) },
|
||||||
}
|
probeTcpReachable = ::probeTcpReachable,
|
||||||
|
discoverConnectService = {
|
||||||
val quickCandidates = savedShortcuts.toList()
|
adbCoordinator.discoverConnectService(
|
||||||
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(
|
|
||||||
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
|
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
|
||||||
includeLanDevices = asBundle.adbMdnsLanDiscovery,
|
includeLanDevices = asBundle.adbMdnsLanDiscovery,
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
onMdnsPortChanged = { host, oldPort, newPort ->
|
||||||
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(
|
savedShortcuts = savedShortcuts.update(
|
||||||
host = discoveredHost, port = portToReplace,
|
host = host,
|
||||||
newPort = discoveredPort,
|
port = oldPort,
|
||||||
|
newPort = newPort,
|
||||||
)
|
)
|
||||||
logEvent(
|
logEvent("mDNS 发现新端口,已更新快速设备: $host:$oldPort -> $host:$newPort")
|
||||||
"mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort"
|
},
|
||||||
)
|
connectKnownShortcut = { target ->
|
||||||
}
|
if (!runAutoAdbConnect(target.host, target.port)) {
|
||||||
|
false
|
||||||
if (adbConnected || adbConnecting) {
|
} else {
|
||||||
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
savedShortcuts = savedShortcuts.update(
|
||||||
continue
|
host = target.host,
|
||||||
}
|
port = target.port,
|
||||||
|
)
|
||||||
try {
|
handleAdbConnected(
|
||||||
runAutoAdbConnect(discoveredHost, discoveredPort)
|
target.host,
|
||||||
adbConnected = true
|
target.port,
|
||||||
savedShortcuts = savedShortcuts.update(
|
scrcpyProfileId = target.scrcpyProfileId,
|
||||||
host = discoveredHost, port = discoveredPort,
|
)
|
||||||
)
|
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
|
||||||
handleAdbConnected(
|
true
|
||||||
discoveredHost,
|
}
|
||||||
discoveredPort,
|
},
|
||||||
scrcpyProfileId = knownDevice.scrcpyProfileId,
|
connectDiscoveredShortcut = { host, port, knownDevice ->
|
||||||
)
|
if (!runAutoAdbConnect(host, port)) {
|
||||||
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
|
false
|
||||||
} catch (_: Exception) {
|
} else {
|
||||||
}
|
savedShortcuts = savedShortcuts.update(host = host, port = port)
|
||||||
|
handleAdbConnected(
|
||||||
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
host,
|
||||||
}
|
port,
|
||||||
|
scrcpyProfileId = knownDevice.scrcpyProfileId,
|
||||||
|
)
|
||||||
|
logEvent("ADB 自动重连成功: $host:$port")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
retryIntervalMs = ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendVirtualButtonAction(action: VirtualButtonAction) {
|
val adbCallbacks = DeviceTabAdbCallbacks(
|
||||||
val keycode = action.keycode ?: return
|
runAdbConnect = { label, onStarted, onFinished, block ->
|
||||||
runBusy("发送 ${action.title}") {
|
runAdbConnect(label, onStarted, onFinished, block)
|
||||||
scrcpy.injectKeycode(0, keycode)
|
},
|
||||||
scrcpy.injectKeycode(1, keycode)
|
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(
|
LazyColumn(
|
||||||
@@ -907,55 +921,7 @@ fun DeviceTabPage(
|
|||||||
onAction = { device ->
|
onAction = { device ->
|
||||||
haptics.contextClick()
|
haptics.contextClick()
|
||||||
if (editingDeviceId == device.id) editingDeviceId = null
|
if (editingDeviceId == device.id) editingDeviceId = null
|
||||||
|
adbCallbacks.onDeviceAction(device)
|
||||||
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 已断开",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onEditorSave = { device, updated ->
|
onEditorSave = { device, updated ->
|
||||||
savedShortcuts = savedShortcuts.update(
|
savedShortcuts = savedShortcuts.update(
|
||||||
@@ -1000,30 +966,7 @@ fun DeviceTabPage(
|
|||||||
onConnect = {
|
onConnect = {
|
||||||
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
|
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
|
||||||
?: return@QuickConnectCard
|
?: return@QuickConnectCard
|
||||||
runAdbConnect(
|
adbCallbacks.onQuickConnect(target)
|
||||||
"连接 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 连接失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1034,28 +977,8 @@ fun DeviceTabPage(
|
|||||||
PairingCard(
|
PairingCard(
|
||||||
busy = busy,
|
busy = busy,
|
||||||
autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen,
|
autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen,
|
||||||
onDiscoverTarget = {
|
onDiscoverTarget = adbCallbacks::onDiscoverPairingTarget,
|
||||||
NativeAdbService.discoverPairingService(
|
onPair = adbCallbacks::onPair,
|
||||||
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 "配对失败")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1080,28 +1003,15 @@ fun DeviceTabPage(
|
|||||||
runBusy("停止 scrcpy") {
|
runBusy("停止 scrcpy") {
|
||||||
scrcpy.stop()
|
scrcpy.stop()
|
||||||
AppWakeLocks.release()
|
AppWakeLocks.release()
|
||||||
statusLine = "${currentTarget!!.host}:${currentTarget.port}"
|
adbSession = adbSession.copy(
|
||||||
|
statusLine = "${currentTarget!!.host}:${currentTarget.port}"
|
||||||
|
)
|
||||||
logEvent("scrcpy 已停止")
|
logEvent("scrcpy 已停止")
|
||||||
snackbar.show("scrcpy 已停止")
|
snackbar.show("scrcpy 已停止")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sessionInfo = sessionInfo,
|
sessionInfo = sessionInfo,
|
||||||
onDisconnect = {
|
onDisconnect = { adbCallbacks.onDisconnectCurrent(currentTarget) },
|
||||||
runAdbConnect(
|
|
||||||
"断开 ADB",
|
|
||||||
onStarted = {},
|
|
||||||
onFinished = {},
|
|
||||||
) {
|
|
||||||
currentTarget?.let { target ->
|
|
||||||
sessionReconnectBlacklistHosts += target.host
|
|
||||||
disconnectAdbConnection(
|
|
||||||
clearQuickOnlineForTarget = target,
|
|
||||||
logMessage = "ADB 已断开",
|
|
||||||
showSnackMessage = "ADB 已断开",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1129,7 +1039,13 @@ fun DeviceTabPage(
|
|||||||
outsideActions = virtualButtonLayout.first,
|
outsideActions = virtualButtonLayout.first,
|
||||||
moreActions = virtualButtonLayout.second,
|
moreActions = virtualButtonLayout.second,
|
||||||
showText = asBundle.previewVirtualButtonShowText,
|
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.Settings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface
|
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.VirtualButtonActions
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -108,7 +109,7 @@ fun FullscreenControlScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val floatingActions = remember(buttonItems) {
|
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 fullscreenDebugInfo = asBundle.fullscreenDebugInfo
|
||||||
val showFullscreenVirtualButtons = asBundle.showFullscreenVirtualButtons
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
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.AppRuntime
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
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.Settings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
||||||
@@ -70,6 +70,7 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||||
import top.yukonga.miuix.kmp.basic.Card
|
import top.yukonga.miuix.kmp.basic.Card
|
||||||
import top.yukonga.miuix.kmp.basic.DropdownImpl
|
import top.yukonga.miuix.kmp.basic.DropdownImpl
|
||||||
import top.yukonga.miuix.kmp.basic.Icon
|
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.ScrollBehavior
|
||||||
import top.yukonga.miuix.kmp.basic.SnackbarHost
|
import top.yukonga.miuix.kmp.basic.SnackbarHost
|
||||||
import top.yukonga.miuix.kmp.basic.SpinnerEntry
|
import top.yukonga.miuix.kmp.basic.SpinnerEntry
|
||||||
|
import top.yukonga.miuix.kmp.basic.TabRow
|
||||||
import top.yukonga.miuix.kmp.basic.TabRowWithContour
|
import top.yukonga.miuix.kmp.basic.TabRowWithContour
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
import top.yukonga.miuix.kmp.basic.TextButton
|
import top.yukonga.miuix.kmp.basic.TextButton
|
||||||
@@ -96,6 +98,7 @@ import top.yukonga.miuix.kmp.preference.ArrowPreference
|
|||||||
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
|
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
|
||||||
import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference
|
import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference
|
||||||
import top.yukonga.miuix.kmp.preference.SwitchPreference
|
import top.yukonga.miuix.kmp.preference.SwitchPreference
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -105,6 +108,7 @@ internal fun ScrcpyAllOptionsScreen(
|
|||||||
) {
|
) {
|
||||||
val navigator = LocalRootNavigator.current
|
val navigator = LocalRootNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbar = LocalSnackbarController.current
|
||||||
var showProfileMenu by rememberSaveable { mutableStateOf(false) }
|
var showProfileMenu by rememberSaveable { mutableStateOf(false) }
|
||||||
var showManageProfilesSheet by rememberSaveable { mutableStateOf(false) }
|
var showManageProfilesSheet by rememberSaveable { mutableStateOf(false) }
|
||||||
val qdBundleShared by quickDevices.bundleState.collectAsState()
|
val qdBundleShared by quickDevices.bundleState.collectAsState()
|
||||||
@@ -139,6 +143,15 @@ internal fun ScrcpyAllOptionsScreen(
|
|||||||
val lastValidSoBundleState = rememberSaveable(selectedProfileId) {
|
val lastValidSoBundleState = rememberSaveable(selectedProfileId) {
|
||||||
mutableStateOf(soBundleState.value)
|
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 currentConnectedDeviceName = remember(qdBundleShared.quickDevicesList) {
|
||||||
val currentTarget = AppRuntime.currentConnectionTarget
|
val currentTarget = AppRuntime.currentConnectionTarget
|
||||||
if (currentTarget == null) {
|
if (currentTarget == null) {
|
||||||
@@ -153,7 +166,8 @@ internal fun ScrcpyAllOptionsScreen(
|
|||||||
}
|
}
|
||||||
var activeProfileDialog by rememberSaveable { mutableStateOf<ProfileDialogMode?>(null) }
|
var activeProfileDialog by rememberSaveable { mutableStateOf<ProfileDialogMode?>(null) }
|
||||||
var profileDialogTargetId by rememberSaveable { mutableStateOf<String?>(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) }
|
var deletingProfileId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
suspend fun saveBundleForProfile(profileId: String, bundle: ScrcpyOptions.Bundle) {
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -214,6 +243,31 @@ internal fun ScrcpyAllOptionsScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior,
|
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 = {
|
snackbarHost = {
|
||||||
@@ -225,51 +279,59 @@ internal fun ScrcpyAllOptionsScreen(
|
|||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
scrcpy = scrcpy,
|
scrcpy = scrcpy,
|
||||||
qdBundleShared = qdBundleShared,
|
|
||||||
soBundleShared = soBundleShared,
|
soBundleShared = soBundleShared,
|
||||||
scrcpyProfilesState = scrcpyProfilesState,
|
scrcpyProfilesState = scrcpyProfilesState,
|
||||||
selectedProfileIdState = selectedProfileIdState,
|
selectedProfileIdState = selectedProfileIdState,
|
||||||
soBundleState = soBundleState,
|
soBundleState = soBundleState,
|
||||||
lastValidSoBundleState = lastValidSoBundleState,
|
lastValidSoBundleState = lastValidSoBundleState,
|
||||||
currentConnectedDeviceName = currentConnectedDeviceName,
|
|
||||||
onSaveBundleForProfile = ::saveBundleForProfile,
|
onSaveBundleForProfile = ::saveBundleForProfile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
ProfileNameDialog(
|
ProfileNameDialog(
|
||||||
mode = activeProfileDialog,
|
mode = activeProfileDialog,
|
||||||
initialInput = profileDialogInput,
|
initialInput = profileDialogInput,
|
||||||
|
profiles = scrcpyProfilesState.profiles,
|
||||||
|
initialCopySourceProfileId = profileDialogCopySourceId,
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
activeProfileDialog = null
|
activeProfileDialog = null
|
||||||
profileDialogTargetId = null
|
profileDialogTargetId = null
|
||||||
},
|
},
|
||||||
onConfirm = { input ->
|
) { input, copySourceProfileId ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
when (activeProfileDialog) {
|
when (activeProfileDialog) {
|
||||||
ProfileDialogMode.Create -> {
|
ProfileDialogMode.Create -> {
|
||||||
saveBundleForProfile(selectedProfileId, soBundleState.value)
|
saveBundleForProfile(selectedProfileId, soBundleState.value)
|
||||||
val created = scrcpyProfiles.createProfile(
|
val copySourceBundle = when (copySourceProfileId) {
|
||||||
requestedName = input,
|
null -> ScrcpyOptions.defaultBundle()
|
||||||
bundle = soBundleState.value,
|
selectedProfileId -> soBundleState.value
|
||||||
)
|
ScrcpyOptions.GLOBAL_PROFILE_ID -> soBundleShared
|
||||||
selectedProfileId = created.id
|
else -> scrcpyProfilesState.profiles
|
||||||
|
.firstOrNull { it.id == copySourceProfileId }
|
||||||
|
?.bundle
|
||||||
|
?: soBundleShared
|
||||||
}
|
}
|
||||||
|
val created = scrcpyProfiles.createProfile(
|
||||||
ProfileDialogMode.Rename -> {
|
requestedName = input,
|
||||||
val profileId = profileDialogTargetId ?: return@launch
|
bundle = copySourceBundle,
|
||||||
scrcpyProfiles.renameProfile(
|
)
|
||||||
id = profileId,
|
selectedProfileId = created.id
|
||||||
requestedName = input,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
null -> Unit
|
|
||||||
}
|
}
|
||||||
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(
|
ManageProfilesSheet(
|
||||||
show = showManageProfilesSheet,
|
show = showManageProfilesSheet,
|
||||||
@@ -278,7 +340,8 @@ internal fun ScrcpyAllOptionsScreen(
|
|||||||
onDismissRequest = { showManageProfilesSheet = false },
|
onDismissRequest = { showManageProfilesSheet = false },
|
||||||
onCreateProfile = {
|
onCreateProfile = {
|
||||||
profileDialogTargetId = null
|
profileDialogTargetId = null
|
||||||
profileDialogInput = ""
|
profileDialogInput = "新配置"
|
||||||
|
profileDialogCopySourceId = selectedProfileId
|
||||||
activeProfileDialog = ProfileDialogMode.Create
|
activeProfileDialog = ProfileDialogMode.Create
|
||||||
},
|
},
|
||||||
onRenameProfile = { profileId ->
|
onRenameProfile = { profileId ->
|
||||||
@@ -304,20 +367,19 @@ internal fun ScrcpyAllOptionsScreen(
|
|||||||
.firstOrNull { it.id == deletingProfileId }
|
.firstOrNull { it.id == deletingProfileId }
|
||||||
?.name.orEmpty(),
|
?.name.orEmpty(),
|
||||||
onDismissRequest = { deletingProfileId = null },
|
onDismissRequest = { deletingProfileId = null },
|
||||||
onConfirm = {
|
) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val profileId = deletingProfileId ?: return@launch
|
val profileId = deletingProfileId ?: return@launch
|
||||||
val deleted = scrcpyProfiles.deleteProfile(profileId)
|
val deleted = scrcpyProfiles.deleteProfile(profileId)
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
rebindDeletedProfileReferences(profileId)
|
rebindDeletedProfileReferences(profileId)
|
||||||
if (selectedProfileId == profileId) {
|
if (selectedProfileId == profileId) {
|
||||||
selectedProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID
|
selectedProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||||
}
|
|
||||||
}
|
}
|
||||||
deletingProfileId = null
|
|
||||||
}
|
}
|
||||||
},
|
deletingProfileId = null
|
||||||
)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,13 +388,11 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
scrollBehavior: ScrollBehavior,
|
scrollBehavior: ScrollBehavior,
|
||||||
scrcpy: Scrcpy,
|
scrcpy: Scrcpy,
|
||||||
qdBundleShared: io.github.miuzarte.scrcpyforandroid.storage.QuickDevices.Bundle,
|
|
||||||
soBundleShared: ScrcpyOptions.Bundle,
|
soBundleShared: ScrcpyOptions.Bundle,
|
||||||
scrcpyProfilesState: io.github.miuzarte.scrcpyforandroid.storage.ScrcpyProfiles.State,
|
scrcpyProfilesState: ScrcpyProfiles.State,
|
||||||
selectedProfileIdState: MutableState<String>,
|
selectedProfileIdState: MutableState<String>,
|
||||||
soBundleState: MutableState<ScrcpyOptions.Bundle>,
|
soBundleState: MutableState<ScrcpyOptions.Bundle>,
|
||||||
lastValidSoBundleState: MutableState<ScrcpyOptions.Bundle>,
|
lastValidSoBundleState: MutableState<ScrcpyOptions.Bundle>,
|
||||||
currentConnectedDeviceName: String?,
|
|
||||||
onSaveBundleForProfile: suspend (String, ScrcpyOptions.Bundle) -> Unit,
|
onSaveBundleForProfile: suspend (String, ScrcpyOptions.Bundle) -> Unit,
|
||||||
) {
|
) {
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
@@ -348,36 +408,12 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
var soBundle by soBundleState
|
var soBundle by soBundleState
|
||||||
var lastValidSoBundle by lastValidSoBundleState
|
var lastValidSoBundle by lastValidSoBundleState
|
||||||
val soBundleLatest by rememberUpdatedState(soBundle)
|
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 {
|
fun resolveProfileBundle(profileId: String): ScrcpyOptions.Bundle {
|
||||||
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) return soBundleShared
|
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) return soBundleShared
|
||||||
return scrcpyProfilesState.profiles.firstOrNull { it.id == profileId }?.bundle
|
return scrcpyProfilesState.profiles.firstOrNull { it.id == profileId }?.bundle
|
||||||
?: soBundleShared
|
?: 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) {
|
LaunchedEffect(selectedProfileId, soBundleShared, scrcpyProfilesState) {
|
||||||
val bundle = resolveProfileBundle(selectedProfileId)
|
val bundle = resolveProfileBundle(selectedProfileId)
|
||||||
if (soBundle != bundle) {
|
if (soBundle != bundle) {
|
||||||
@@ -715,32 +751,6 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
scrollBehavior = scrollBehavior,
|
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 {
|
item {
|
||||||
Card {
|
Card {
|
||||||
TextField(
|
TextField(
|
||||||
@@ -1922,7 +1932,7 @@ private fun ProfileMenuPopupItem(
|
|||||||
top = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem,
|
top = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem,
|
||||||
bottom = if (index == optionSize - 1) 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,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1931,19 +1941,32 @@ private fun ProfileMenuPopupItem(
|
|||||||
private fun ProfileNameDialog(
|
private fun ProfileNameDialog(
|
||||||
mode: ProfileDialogMode?,
|
mode: ProfileDialogMode?,
|
||||||
initialInput: String,
|
initialInput: String,
|
||||||
|
profiles: List<ScrcpyProfiles.Profile>,
|
||||||
|
initialCopySourceProfileId: String?,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onConfirm: (String) -> Unit,
|
onConfirm: (String, String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (mode == null) return
|
if (mode == null) return
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
var input by rememberSaveable(mode, initialInput) { mutableStateOf(initialInput) }
|
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(
|
OverlayDialog(
|
||||||
show = true,
|
show = true,
|
||||||
title = when (mode) {
|
title = when (mode) {
|
||||||
ProfileDialogMode.Create -> "新建配置"
|
ProfileDialogMode.Create -> "新建配置"
|
||||||
ProfileDialogMode.Rename -> "重命名配置"
|
ProfileDialogMode.Rename -> "重命名配置"
|
||||||
},
|
},
|
||||||
summary = "名称重复时会自动追加序号",
|
summary = "配置名重复时会自动追加序号",
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -1952,7 +1975,7 @@ private fun ProfileNameDialog(
|
|||||||
TextField(
|
TextField(
|
||||||
value = input,
|
value = input,
|
||||||
onValueChange = { input = it },
|
onValueChange = { input = it },
|
||||||
label = "配置名称",
|
label = "配置名",
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
@@ -1960,19 +1983,35 @@ private fun ProfileNameDialog(
|
|||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
|
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
|
||||||
) {
|
) {
|
||||||
top.yukonga.miuix.kmp.basic.TextButton(
|
TextButton(
|
||||||
text = "取消",
|
text = "取消",
|
||||||
onClick = onDismissRequest,
|
onClick = onDismissRequest,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
top.yukonga.miuix.kmp.basic.TextButton(
|
TextButton(
|
||||||
text = "确定",
|
text = "确定",
|
||||||
onClick = { onConfirm(input) },
|
onClick = { onConfirm(input, copySourceProfileId) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
colors = top.yukonga.miuix.kmp.basic.ButtonDefaults.textButtonColorsPrimary(),
|
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1982,7 +2021,7 @@ private fun ProfileNameDialog(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ManageProfilesSheet(
|
private fun ManageProfilesSheet(
|
||||||
show: Boolean,
|
show: Boolean,
|
||||||
profiles: List<io.github.miuzarte.scrcpyforandroid.storage.ScrcpyProfiles.Profile>,
|
profiles: List<ScrcpyProfiles.Profile>,
|
||||||
selectedProfileId: String,
|
selectedProfileId: String,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onCreateProfile: () -> Unit,
|
onCreateProfile: () -> Unit,
|
||||||
@@ -2073,7 +2112,7 @@ private fun DeleteProfileDialog(
|
|||||||
text = "删除",
|
text = "删除",
|
||||||
onClick = onConfirm,
|
onClick = onConfirm,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
colors = top.yukonga.miuix.kmp.basic.ButtonDefaults.textButtonColorsPrimary(),
|
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,43 +125,42 @@ private fun SliderInputDialog(
|
|||||||
summary = summary,
|
summary = summary,
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
onDismissFinished = onDismissFinished,
|
onDismissFinished = onDismissFinished,
|
||||||
content = {
|
) {
|
||||||
var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) }
|
var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
SuperTextField(
|
SuperTextField(
|
||||||
modifier = Modifier.padding(bottom = 16.dp),
|
modifier = Modifier.padding(bottom = 16.dp),
|
||||||
value = text,
|
value = text,
|
||||||
label = label,
|
label = label,
|
||||||
useLabelAsPlaceholder = useLabelAsPlaceholder,
|
useLabelAsPlaceholder = useLabelAsPlaceholder,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Number,
|
keyboardType = KeyboardType.Number,
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
onValueChange = { newValue ->
|
onValueChange = { newValue ->
|
||||||
text = inputFilter(newValue)
|
text = inputFilter(newValue)
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
TextButton(
|
||||||
|
text = "取消",
|
||||||
|
onClick = onDismissRequest,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(20.dp))
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween) {
|
TextButton(
|
||||||
TextButton(
|
text = "确定",
|
||||||
text = "取消",
|
onClick = {
|
||||||
onClick = onDismissRequest,
|
val inputValue = text.toFloatOrNull() ?: 0f
|
||||||
modifier = Modifier.weight(1f),
|
if (inputValue >= inputValueRange.start && inputValue <= inputValueRange.endInclusive) {
|
||||||
)
|
onConfirm(text.trim())
|
||||||
Spacer(Modifier.width(20.dp))
|
}
|
||||||
TextButton(
|
},
|
||||||
text = "确定",
|
modifier = Modifier.weight(1f),
|
||||||
onClick = {
|
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||||
val inputValue = text.toFloatOrNull() ?: 0f
|
)
|
||||||
if (inputValue >= inputValueRange.start && inputValue <= inputValueRange.endInclusive) {
|
}
|
||||||
onConfirm(text.trim())
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
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,
|
onDiscoverTarget = onDiscoverTarget,
|
||||||
onDismissRequest = { showPairDialog.value = false },
|
onDismissRequest = { showPairDialog.value = false },
|
||||||
onDismissFinished = { holdDownState.value = false },
|
onDismissFinished = { holdDownState.value = false },
|
||||||
onConfirm = { host, port, code ->
|
) { host, port, code ->
|
||||||
showPairDialog.value = false
|
showPairDialog.value = false
|
||||||
onPair(host, port, code)
|
onPair(host, port, code)
|
||||||
},
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -673,95 +672,94 @@ private fun PairingDialog(
|
|||||||
onDismissFinished = {
|
onDismissFinished = {
|
||||||
onDismissFinished()
|
onDismissFinished()
|
||||||
},
|
},
|
||||||
content = {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
||||||
) {
|
) {
|
||||||
TextField(
|
TextField(
|
||||||
value = host,
|
value = host,
|
||||||
onValueChange = { host = it },
|
onValueChange = { host = it },
|
||||||
label = "IP 地址",
|
label = "IP 地址",
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Number,
|
keyboardType = KeyboardType.Number,
|
||||||
imeAction = ImeAction.Next,
|
imeAction = ImeAction.Next,
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
onNext = { focusManager.moveFocus(FocusDirection.Next) },
|
onNext = { focusManager.moveFocus(FocusDirection.Next) },
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
TextField(
|
TextField(
|
||||||
value = port,
|
value = port,
|
||||||
onValueChange = { port = it.filter(Char::isDigit) },
|
onValueChange = { port = it.filter(Char::isDigit) },
|
||||||
label = "端口",
|
label = "端口",
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Number,
|
keyboardType = KeyboardType.Number,
|
||||||
imeAction = ImeAction.Next,
|
imeAction = ImeAction.Next,
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
onNext = { focusManager.moveFocus(FocusDirection.Next) },
|
onNext = { focusManager.moveFocus(FocusDirection.Next) },
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
TextField(
|
TextField(
|
||||||
value = code,
|
value = code,
|
||||||
onValueChange = { code = it },
|
onValueChange = { code = it },
|
||||||
label = "WLAN 配对码",
|
label = "WLAN 配对码",
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Number,
|
keyboardType = KeyboardType.Number,
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
onDone = { focusManager.clearFocus() },
|
onDone = { focusManager.clearFocus() },
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(UiSpacing.ContentVertical * 2))
|
Spacer(Modifier.height(UiSpacing.ContentVertical * 2))
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
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(
|
TextButton(
|
||||||
text = if (!discoveringPort) "自动发现" else "发现中...",
|
text = "取消",
|
||||||
onClick = {
|
onClick = {
|
||||||
if (enabled && onDiscoverTarget != null && !discoveringPort)
|
onDismissRequest()
|
||||||
scope.launch { doDiscover() }
|
|
||||||
},
|
},
|
||||||
enabled = enabled && onDiscoverTarget != null && !discoveringPort,
|
modifier = Modifier.weight(1f),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
)
|
||||||
|
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(
|
SuperTextField(
|
||||||
value = currentDraft.name,
|
value = currentDraft.name,
|
||||||
onValueChange = { draft = currentDraft.copy(name = it) },
|
onValueChange = { draft = currentDraft.copy(name = it) },
|
||||||
label = "设备名称",
|
label = "设备名",
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
@@ -1101,7 +1099,7 @@ internal fun DeviceTile(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
OverlayDropdownPreference(
|
OverlayDropdownPreference(
|
||||||
title = "Scrcpy 配置",
|
title = "scrcpy 配置",
|
||||||
items = profileNames,
|
items = profileNames,
|
||||||
selectedIndex = profileDropdownIndex,
|
selectedIndex = profileDropdownIndex,
|
||||||
onSelectedIndexChange = {
|
onSelectedIndexChange = {
|
||||||
|
|||||||
Reference in New Issue
Block a user