feat: multiple IPs to one device (#30)
- 新增: 设备支持多个 IP, 自动选择延迟最低的路径 - 新增: 允许让预览卡显示在配置卡上方
This commit is contained in:
@@ -5,6 +5,7 @@ import java.util.Properties
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
@@ -150,6 +151,7 @@ dependencies {
|
||||
implementation(libs.bcpkix.jdk18on)
|
||||
implementation(libs.conscrypt.android)
|
||||
implementation(libs.reorderable)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
implementation(libs.androidx.biometric)
|
||||
|
||||
@@ -4,42 +4,106 @@ import android.os.Parcelable
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// Composable 用, 不可变 List
|
||||
class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> by devices {
|
||||
fun marshalToString(
|
||||
separator: String = DEFAULT_SEPARATOR,
|
||||
): String = joinToString(separator) { it.marshalToString() }
|
||||
fun marshalToString(): String = DeviceShortcut.json.encodeToString(devices)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_SEPARATOR = "\n"
|
||||
|
||||
fun unmarshalFrom(
|
||||
s: String,
|
||||
separator: String = DEFAULT_SEPARATOR,
|
||||
): DeviceShortcuts {
|
||||
fun unmarshalFrom(s: String): DeviceShortcuts {
|
||||
if (s.isBlank()) return DeviceShortcuts(emptyList())
|
||||
val trimmed = s.trim()
|
||||
if (trimmed.startsWith("[")) {
|
||||
return runCatching {
|
||||
val list = DeviceShortcut.json.decodeFromString<List<DeviceShortcut>>(trimmed)
|
||||
.map {
|
||||
if (it.addresses.isEmpty()) it.copy(addresses = listOf(""))
|
||||
else it
|
||||
}
|
||||
DeviceShortcuts(list)
|
||||
}.getOrDefault(DeviceShortcuts(emptyList()))
|
||||
}
|
||||
return unmarshalFromPipe(s)
|
||||
}
|
||||
|
||||
private fun unmarshalFromPipe(s: String): DeviceShortcuts {
|
||||
var nextLegacyId = 1
|
||||
val list = s.splitToSequence(separator)
|
||||
val list = s.lines()
|
||||
.mapNotNull { raw ->
|
||||
val firstValue = raw.split(DeviceShortcut.DEFAULT_SEPARATOR, limit = 2)
|
||||
val firstValue = raw.split("|", limit = 2)
|
||||
.firstOrNull()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (firstValue.toIntOrNull() != null) {
|
||||
DeviceShortcut.unmarshalFrom(raw)
|
||||
unmarshalDeviceFromPipe(raw)
|
||||
} else {
|
||||
DeviceShortcut.unmarshalFrom(
|
||||
s = raw,
|
||||
unmarshalDeviceFromPipe(
|
||||
raw,
|
||||
fallbackId = nextLegacyId.toString(),
|
||||
).also {
|
||||
if (it != null) nextLegacyId++
|
||||
}
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
return DeviceShortcuts(list)
|
||||
}
|
||||
|
||||
private fun unmarshalDeviceFromPipe(
|
||||
s: String,
|
||||
fallbackId: String? = null,
|
||||
): DeviceShortcut? {
|
||||
val parts = s.split("|")
|
||||
val idInData = parts.firstOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.toIntOrNull() != null }
|
||||
|
||||
return if (idInData != null) {
|
||||
if (parts.size == 7) {
|
||||
val name = parts[1].trim()
|
||||
val host = parts[2].trim()
|
||||
val port = parts[3].trim().toIntOrNull() ?: Defaults.ADB_PORT
|
||||
val startScrcpyOnConnect = parts.getOrNull(4)?.trim() == "1"
|
||||
val openFullscreenOnStart = startScrcpyOnConnect
|
||||
&& parts.getOrNull(5)?.trim() == "1"
|
||||
val scrcpyProfileId = parts.getOrNull(6)
|
||||
?.trim()
|
||||
?.takeUnless { it.isNullOrBlank() }
|
||||
?: ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
|
||||
if (host.isNotBlank()) DeviceShortcut(
|
||||
id = idInData,
|
||||
name = name,
|
||||
addresses = listOf("$host:$port"),
|
||||
startScrcpyOnConnect = startScrcpyOnConnect,
|
||||
openFullscreenOnStart = openFullscreenOnStart,
|
||||
scrcpyProfileId = scrcpyProfileId,
|
||||
)
|
||||
else null
|
||||
} else null
|
||||
} else if (fallbackId != null && parts.size == 6) {
|
||||
val name = parts[0].trim()
|
||||
val host = parts[1].trim()
|
||||
val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT
|
||||
val startScrcpyOnConnect = parts.getOrNull(3)?.trim() == "1"
|
||||
val openFullscreenOnStart = startScrcpyOnConnect
|
||||
&& parts.getOrNull(4)?.trim() == "1"
|
||||
val scrcpyProfileId = parts.getOrNull(5)
|
||||
?.trim()
|
||||
?.takeUnless { it.isNullOrBlank() }
|
||||
?: ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
|
||||
if (host.isNotBlank()) DeviceShortcut(
|
||||
id = fallbackId,
|
||||
name = name,
|
||||
addresses = listOf("$host:$port"),
|
||||
startScrcpyOnConnect = startScrcpyOnConnect,
|
||||
openFullscreenOnStart = openFullscreenOnStart,
|
||||
scrcpyProfileId = scrcpyProfileId,
|
||||
)
|
||||
else null
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getIndex(id: String) = devices.indexOfFirst { it.id == id }
|
||||
@@ -71,6 +135,25 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
|
||||
val old = devices[idx]
|
||||
val updateById = id != null
|
||||
|
||||
val updatedAddresses = when {
|
||||
updateById -> {
|
||||
val h = host ?: old.host
|
||||
val p = port ?: old.port
|
||||
listOf("$h:$p")
|
||||
}
|
||||
|
||||
newPort != null -> {
|
||||
old.addresses.map { addr ->
|
||||
val parsed = ConnectionTarget.unmarshalFrom(addr)
|
||||
if (parsed != null && parsed.host == host && parsed.port == port) {
|
||||
"${parsed.host}:$newPort"
|
||||
} else addr
|
||||
}
|
||||
}
|
||||
|
||||
else -> old.addresses
|
||||
}
|
||||
|
||||
val updated = DeviceShortcut(
|
||||
id = old.id,
|
||||
name = when {
|
||||
@@ -78,14 +161,12 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
|
||||
updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name
|
||||
else -> name
|
||||
},
|
||||
host = if (updateById) host ?: old.host else old.host,
|
||||
port = if (updateById) port ?: old.port else newPort ?: old.port,
|
||||
addresses = updatedAddresses,
|
||||
startScrcpyOnConnect = startScrcpyOnConnect ?: old.startScrcpyOnConnect,
|
||||
openFullscreenOnStart = openFullscreenOnStart ?: old.openFullscreenOnStart,
|
||||
scrcpyProfileId = scrcpyProfileId ?: old.scrcpyProfileId,
|
||||
)
|
||||
|
||||
// 若无任何变化,返回原实例
|
||||
if (updated == old) return this
|
||||
|
||||
val newList = devices.toMutableList()
|
||||
@@ -93,7 +174,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
|
||||
this[idx] = updated
|
||||
}
|
||||
return DeviceShortcuts(
|
||||
if ((updateById && (updated.host != old.host || updated.port != old.port))
|
||||
if ((updateById && (updated.addresses != old.addresses))
|
||||
|| (newPort != null && newPort != old.port)
|
||||
)
|
||||
newList.distinctBy { it.id }
|
||||
@@ -114,7 +195,6 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
|
||||
}
|
||||
val newList = devices.toMutableList()
|
||||
if (existingIdx >= 0) {
|
||||
// Keep existing id stable when matching by host:port.
|
||||
val existingId = devices[existingIdx].id
|
||||
newList[existingIdx] = normalizedShortcut.copy(id = existingId)
|
||||
} else {
|
||||
@@ -139,119 +219,51 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
|
||||
if (fromIndex == toIndex) return this
|
||||
val mutable = devices.toMutableList()
|
||||
val item = mutable.removeAt(fromIndex)
|
||||
// 如果目标位置在原位置之后,移除后列表长度减1,因此目标索引需减1
|
||||
val target = if (toIndex > fromIndex) toIndex - 1 else toIndex
|
||||
mutable.add(target, item)
|
||||
return DeviceShortcuts(mutable)
|
||||
}
|
||||
|
||||
// 删除指定设备
|
||||
fun remove(id: String) = DeviceShortcuts(devices.filterNot { it.id == id })
|
||||
|
||||
// 清空所有设备
|
||||
fun clear() = DeviceShortcuts(emptyList())
|
||||
|
||||
// 复制当前实例
|
||||
fun copy(devices: List<DeviceShortcut> = this.devices): DeviceShortcuts =
|
||||
DeviceShortcuts(devices)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class DeviceShortcut(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val host: String,
|
||||
val port: Int = Defaults.ADB_PORT,
|
||||
val addresses: List<String> = listOf(""),
|
||||
val startScrcpyOnConnect: Boolean = false,
|
||||
val openFullscreenOnStart: Boolean = false,
|
||||
val scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||
) {
|
||||
fun marshalToString(
|
||||
separator: String = DEFAULT_SEPARATOR,
|
||||
): String = listOf(
|
||||
id.trim(),
|
||||
name.trim(),
|
||||
host.trim(),
|
||||
port.toString(),
|
||||
if (startScrcpyOnConnect) "1" else "0",
|
||||
if (openFullscreenOnStart) "1" else "0",
|
||||
scrcpyProfileId.trim(),
|
||||
).joinToString(
|
||||
separator = separator,
|
||||
)
|
||||
val host: String
|
||||
get() {
|
||||
val first = addresses.firstOrNull() ?: return ""
|
||||
return ConnectionTarget.unmarshalFrom(first)?.host ?: ""
|
||||
}
|
||||
|
||||
val port: Int
|
||||
get() {
|
||||
val first = addresses.firstOrNull() ?: return Defaults.ADB_PORT
|
||||
return ConnectionTarget.unmarshalFrom(first)?.port ?: Defaults.ADB_PORT
|
||||
}
|
||||
|
||||
fun matchesAddress(target: ConnectionTarget) = addresses.any { addr ->
|
||||
val ct = ConnectionTarget.unmarshalFrom(addr)
|
||||
ct != null && ct.host == target.host && ct.port == target.port
|
||||
}
|
||||
|
||||
fun matchesHost(host: String) = addresses.any { addr ->
|
||||
ConnectionTarget.unmarshalFrom(addr)?.host == host
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_SEPARATOR = "|"
|
||||
fun unmarshalFrom(
|
||||
s: String,
|
||||
separator: String = DEFAULT_SEPARATOR,
|
||||
fallbackId: String? = null,
|
||||
): DeviceShortcut? {
|
||||
val parts = s.split(separator)
|
||||
val idInData = parts.firstOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.toIntOrNull() != null }
|
||||
return if (idInData != null) when (parts.size) {
|
||||
4, 5, 6, 7 -> {
|
||||
val name = parts[1].trim()
|
||||
val host = parts[2].trim()
|
||||
val port = parts[3].trim().toIntOrNull() ?: Defaults.ADB_PORT
|
||||
|
||||
val startScrcpyOnConnect = parts.getOrNull(4)
|
||||
?.trim() == "1"
|
||||
val openFullscreenOnStart = startScrcpyOnConnect
|
||||
&& parts.getOrNull(5)
|
||||
?.trim() == "1"
|
||||
val scrcpyProfileId = parts.getOrNull(6)
|
||||
?.trim()
|
||||
.takeUnless { it.isNullOrBlank() }
|
||||
?: ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
|
||||
if (host.isNotBlank()) DeviceShortcut(
|
||||
id = idInData,
|
||||
name = name,
|
||||
host = host,
|
||||
port = port,
|
||||
startScrcpyOnConnect = startScrcpyOnConnect,
|
||||
openFullscreenOnStart = openFullscreenOnStart,
|
||||
scrcpyProfileId = scrcpyProfileId,
|
||||
)
|
||||
else null
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
else when (parts.size) {
|
||||
3, 4, 5, 6 -> {
|
||||
val id = fallbackId ?: return null
|
||||
val name = parts[0].trim()
|
||||
val host = parts[1].trim()
|
||||
val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT
|
||||
|
||||
val startScrcpyOnConnect = parts.getOrNull(3)
|
||||
?.trim() == "1"
|
||||
val openFullscreenOnStart = startScrcpyOnConnect
|
||||
&& parts.getOrNull(4)
|
||||
?.trim() == "1"
|
||||
val scrcpyProfileId = parts.getOrNull(5)
|
||||
?.trim()
|
||||
.takeUnless { it.isNullOrBlank() }
|
||||
?: ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
|
||||
if (host.isNotBlank()) DeviceShortcut(
|
||||
id = id,
|
||||
name = name,
|
||||
host = host,
|
||||
port = port,
|
||||
startScrcpyOnConnect = startScrcpyOnConnect,
|
||||
openFullscreenOnStart = openFullscreenOnStart,
|
||||
scrcpyProfileId = scrcpyProfileId,
|
||||
)
|
||||
else null
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -317,10 +317,10 @@ internal fun DeviceTabPage(
|
||||
fun DeviceListSection() {
|
||||
DeviceTileList(
|
||||
devices = savedShortcuts,
|
||||
currentTarget = currentTarget,
|
||||
isConnected = { device ->
|
||||
adbConnected
|
||||
&& currentTarget?.host == device.host
|
||||
&& currentTarget?.port == device.port
|
||||
&& currentTarget?.let { device.matchesAddress(it) } == true
|
||||
},
|
||||
actionEnabled = !busy && !adbConnecting,
|
||||
actionInProgress = { device -> adbConnecting && activeDeviceActionId == device.id },
|
||||
@@ -331,8 +331,7 @@ internal fun DeviceTabPage(
|
||||
},
|
||||
onLongClick = { device ->
|
||||
val connected = adbConnected
|
||||
&& currentTarget?.host == device.host
|
||||
&& currentTarget?.port == device.port
|
||||
&& currentTarget?.let { device.matchesAddress(it) } == true
|
||||
if (connected) {
|
||||
AppRuntime.snackbar(R.string.device_cannot_modify_connected)
|
||||
} else {
|
||||
@@ -347,16 +346,8 @@ internal fun DeviceTabPage(
|
||||
if (editingDeviceId == device.id) viewModel.setEditingDeviceId(null)
|
||||
viewModel.onDeviceAction(device)
|
||||
},
|
||||
onEditorSave = { device, updated ->
|
||||
viewModel.updateShortcut(
|
||||
id = device.id,
|
||||
name = updated.name,
|
||||
host = updated.host,
|
||||
port = updated.port,
|
||||
startScrcpyOnConnect = updated.startScrcpyOnConnect,
|
||||
openFullscreenOnStart = updated.openFullscreenOnStart,
|
||||
scrcpyProfileId = updated.scrcpyProfileId,
|
||||
)
|
||||
onEditorSave = { _, updated ->
|
||||
viewModel.upsertShortcut(updated)
|
||||
},
|
||||
onEditorDelete = { device ->
|
||||
viewModel.removeShortcut(device.id)
|
||||
@@ -376,7 +367,7 @@ internal fun DeviceTabPage(
|
||||
onAddDevice = {
|
||||
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
|
||||
?: return@QuickConnectCard
|
||||
viewModel.upsertShortcut(DeviceShortcut(host = target.host, port = target.port))
|
||||
viewModel.upsertShortcut(DeviceShortcut(addresses = listOf(target.toString())))
|
||||
AppRuntime.snackbar(R.string.device_added, target.host, target.port)
|
||||
},
|
||||
onConnect = {
|
||||
@@ -400,7 +391,6 @@ internal fun DeviceTabPage(
|
||||
|
||||
@Composable
|
||||
fun ScrcpyConfigSection() {
|
||||
SectionSmallTitle("Scrcpy")
|
||||
ConfigPanel(
|
||||
busy = busy,
|
||||
activeProfileId = connectedScrcpyProfileId,
|
||||
@@ -511,7 +501,6 @@ internal fun DeviceTabPage(
|
||||
|
||||
@Composable
|
||||
fun ScrcpyConfigSectionForTwoPane() {
|
||||
SectionSmallTitle("Scrcpy")
|
||||
ConfigPanel(
|
||||
busy = busy,
|
||||
activeProfileId = connectedScrcpyProfileId,
|
||||
@@ -619,14 +608,26 @@ internal fun DeviceTabPage(
|
||||
}
|
||||
|
||||
if (adbConnected) {
|
||||
item {
|
||||
if (useTwoPaneConfigPanel) ScrcpyConfigSectionForTwoPane()
|
||||
else ScrcpyConfigSection()
|
||||
}
|
||||
|
||||
if (includeInlinePreviewControls && canShowPreviewControls) {
|
||||
item(key = PREVIEW_CARD_ITEM_KEY) { PreviewSection() }
|
||||
if (includeInlinePreviewControls && canShowPreviewControls && asBundle.previewCardOnTop) {
|
||||
item(key = PREVIEW_CARD_ITEM_KEY) {
|
||||
SectionSmallTitle(stringResource(R.string.device_section_scrcpy))
|
||||
PreviewSection()
|
||||
}
|
||||
item { VirtualButtonsSection() }
|
||||
item {
|
||||
if (useTwoPaneConfigPanel) ScrcpyConfigSectionForTwoPane()
|
||||
else ScrcpyConfigSection()
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
SectionSmallTitle(stringResource(R.string.device_section_scrcpy))
|
||||
if (useTwoPaneConfigPanel) ScrcpyConfigSectionForTwoPane()
|
||||
else ScrcpyConfigSection()
|
||||
}
|
||||
if (includeInlinePreviewControls && canShowPreviewControls) {
|
||||
item(key = PREVIEW_CARD_ITEM_KEY) { PreviewSection() }
|
||||
item { VirtualButtonsSection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -481,8 +481,8 @@ internal class DeviceTabViewModel(
|
||||
}
|
||||
|
||||
suspend fun disconnectCurrentTargetBeforeConnecting(newHost: String, newPort: Int) {
|
||||
val disconnected =
|
||||
connectionController.disconnectCurrentTargetBeforeConnecting(newHost, newPort) ?: return
|
||||
val disconnected = connectionController.disconnectCurrentTargetBeforeConnecting(newHost, newPort)
|
||||
?: return
|
||||
sessionReconnectBlacklistHosts += disconnected.host
|
||||
if (disconnected.host.isNotBlank())
|
||||
_savedShortcuts.update { it.update(host = disconnected.host, port = disconnected.port) }
|
||||
@@ -492,6 +492,16 @@ internal class DeviceTabViewModel(
|
||||
connectionController.connectWithTimeout(host, port, ADB_CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
suspend fun connectAddresses(addresses: List<String>): ConnectionTarget {
|
||||
return connectionController.connectAddresses(addresses, ADB_CONNECT_TIMEOUT_MS, ADB_TCP_PROBE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
suspend fun disconnectCurrentTargetBeforeConnectingAny(addresses: List<String>) {
|
||||
val disconnected = connectionController.disconnectCurrentTargetBeforeConnectingAny(addresses)
|
||||
?: return
|
||||
sessionReconnectBlacklistHosts += disconnected.host
|
||||
}
|
||||
|
||||
fun applyConnectedDeviceCapabilities(sdkInt: Int) {
|
||||
connectionController.applyConnectedDeviceCapabilities(sdkInt)
|
||||
}
|
||||
@@ -710,8 +720,7 @@ internal class DeviceTabViewModel(
|
||||
|
||||
fun onDeviceAction(device: DeviceShortcut) {
|
||||
val connected = adbConnected.value
|
||||
&& currentTarget.value?.host == device.host
|
||||
&& currentTarget.value?.port == device.port
|
||||
&& currentTarget.value?.let { device.matchesAddress(it) } == true
|
||||
|
||||
if (!connected) {
|
||||
runAdbConnect(
|
||||
@@ -719,12 +728,12 @@ internal class DeviceTabViewModel(
|
||||
onStarted = { _activeDeviceActionId.value = device.id },
|
||||
onFinished = { _activeDeviceActionId.value = null },
|
||||
) {
|
||||
disconnectCurrentTargetBeforeConnecting(device.host, device.port)
|
||||
disconnectCurrentTargetBeforeConnectingAny(device.addresses)
|
||||
try {
|
||||
connectWithTimeout(device.host, device.port)
|
||||
val matched = connectAddresses(device.addresses)
|
||||
handleAdbConnected(
|
||||
host = device.host,
|
||||
port = device.port,
|
||||
host = matched.host,
|
||||
port = matched.port,
|
||||
autoStartScrcpy = device.startScrcpyOnConnect,
|
||||
autoEnterFullScreen = device.startScrcpyOnConnect && device.openFullscreenOnStart,
|
||||
scrcpyProfileId = device.scrcpyProfileId,
|
||||
|
||||
@@ -429,6 +429,14 @@ fun SettingsPage(
|
||||
)
|
||||
},
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(R.string.pref_title_preview_card_on_top),
|
||||
summary = stringResource(R.string.pref_summary_preview_card_on_top),
|
||||
checked = asBundle.previewCardOnTop,
|
||||
onCheckedChange = {
|
||||
asBundle = asBundle.copy(previewCardOnTop = it)
|
||||
},
|
||||
)
|
||||
ArrowSlider(
|
||||
title = stringResource(R.string.pref_title_preview_card_height),
|
||||
summary = stringResource(R.string.pref_summary_preview_card_height),
|
||||
|
||||
@@ -69,6 +69,31 @@ internal class ConnectionController(
|
||||
adbCoordinator.connectWithTimeout(host, port, timeoutMs)
|
||||
}
|
||||
|
||||
suspend fun connectAddresses(
|
||||
addresses: List<String>,
|
||||
timeoutMs: Long,
|
||||
probeTimeoutMs: Int,
|
||||
): ConnectionTarget {
|
||||
return adbCoordinator.connectFirstReachable(addresses, timeoutMs, probeTimeoutMs)
|
||||
}
|
||||
|
||||
suspend fun disconnectCurrentTargetBeforeConnectingAny(
|
||||
addresses: List<String>,
|
||||
): ConnectionTarget? {
|
||||
val current = state.adbSession.currentTarget ?: return null
|
||||
if (!state.adbSession.isConnected) return null
|
||||
if (addresses.any { addr ->
|
||||
val ct = ConnectionTarget.unmarshalFrom(addr)
|
||||
ct != null && ct.host == current.host && ct.port == current.port
|
||||
}) return null
|
||||
|
||||
disconnectAdbConnection(
|
||||
clearQuickOnlineForTarget = current,
|
||||
cause = DisconnectCause.SwitchTarget,
|
||||
)
|
||||
return current
|
||||
}
|
||||
|
||||
suspend fun keepAliveCheck(timeoutMs: Long): Boolean {
|
||||
return adbCoordinator.isConnected(timeoutMs)
|
||||
}
|
||||
|
||||
@@ -70,16 +70,16 @@ internal class DeviceAdbAutoReconnectManager(
|
||||
},
|
||||
discoverConnectService = discoverConnectService,
|
||||
onMdnsPortChanged = onMdnsPortChanged,
|
||||
connectKnownShortcut = { target ->
|
||||
if (!controller.runAutoAdbConnect(target.host, target.port, connectTimeoutMs)) {
|
||||
connectKnownShortcut = { device, addressTarget ->
|
||||
if (!controller.runAutoAdbConnect(addressTarget.host, addressTarget.port, connectTimeoutMs)) {
|
||||
false
|
||||
} else {
|
||||
controller.handleAdbConnected(
|
||||
host = target.host,
|
||||
port = target.port,
|
||||
scrcpyProfileId = target.scrcpyProfileId,
|
||||
host = addressTarget.host,
|
||||
port = addressTarget.port,
|
||||
scrcpyProfileId = device.scrcpyProfileId,
|
||||
)
|
||||
onKnownDeviceReconnected(target)
|
||||
onKnownDeviceReconnected(device)
|
||||
true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.services
|
||||
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.Closeable
|
||||
@@ -64,7 +65,7 @@ internal class DeviceAdbBackgroundRunner: Closeable {
|
||||
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,
|
||||
connectKnownShortcut: suspend (DeviceShortcut, ConnectionTarget) -> Boolean,
|
||||
connectDiscoveredShortcut: suspend (
|
||||
host: String,
|
||||
port: Int,
|
||||
@@ -81,18 +82,17 @@ internal class DeviceAdbBackgroundRunner: Closeable {
|
||||
|
||||
val quickCandidates = savedShortcuts()
|
||||
if (quickCandidates.isNotEmpty()) {
|
||||
for (target in quickCandidates) {
|
||||
for (device 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
|
||||
for (addr in device.addresses) {
|
||||
if (isConnected() || isAdbConnecting()) break
|
||||
val target = ConnectionTarget.unmarshalFrom(addr) ?: continue
|
||||
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(device, target)) break
|
||||
}
|
||||
}
|
||||
if (isConnected()) break
|
||||
@@ -110,17 +110,21 @@ internal class DeviceAdbBackgroundRunner: Closeable {
|
||||
continue
|
||||
}
|
||||
|
||||
val knownDevice = savedShortcuts().firstOrNull { it.host == discoveredHost }
|
||||
val knownDevice = savedShortcuts().firstOrNull { it.matchesHost(discoveredHost) }
|
||||
if (knownDevice == null) {
|
||||
delay(retryIntervalMs)
|
||||
continue
|
||||
}
|
||||
|
||||
val portToReplace = savedShortcuts().firstOrNull {
|
||||
it.host == discoveredHost &&
|
||||
it.port != knownDevice.port &&
|
||||
it.port != discoveredPort
|
||||
}?.port
|
||||
val portToReplace = savedShortcuts()
|
||||
.filter { it != knownDevice }
|
||||
.firstNotNullOfOrNull { device ->
|
||||
device.addresses.firstNotNullOfOrNull { addr ->
|
||||
val ct = ConnectionTarget.unmarshalFrom(addr)
|
||||
if (ct != null && ct.host == discoveredHost && ct.port != discoveredPort) ct.port
|
||||
else null
|
||||
}
|
||||
}
|
||||
if (portToReplace != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onMdnsPortChanged(discoveredHost, portToReplace, discoveredPort)
|
||||
|
||||
@@ -36,6 +36,42 @@ internal class DeviceAdbConnectionCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectFirstReachable(
|
||||
addresses: List<String>,
|
||||
timeoutMs: Long,
|
||||
probeTimeoutMs: Int,
|
||||
): ConnectionTarget {
|
||||
var lastError: Throwable? = null
|
||||
return withContext(Dispatchers.IO) {
|
||||
val candidates = addresses.mapNotNull { addr ->
|
||||
val target = ConnectionTarget.unmarshalFrom(addr) ?: return@mapNotNull null
|
||||
val resolved = resolveHost(target.host)
|
||||
val latencyNs = runCatching {
|
||||
val startNs = System.nanoTime()
|
||||
Socket().use { socket ->
|
||||
socket.connect(InetSocketAddress(resolved, target.port), probeTimeoutMs)
|
||||
}
|
||||
System.nanoTime() - startNs
|
||||
}.getOrElse { e ->
|
||||
lastError = e
|
||||
return@mapNotNull null
|
||||
}
|
||||
Triple(latencyNs, target, resolved)
|
||||
}.sortedBy { it.first }
|
||||
for ((_, target, resolved) in candidates) {
|
||||
try {
|
||||
withTimeout(timeoutMs) {
|
||||
adbService.connect(resolved, target.port)
|
||||
}
|
||||
return@withContext target
|
||||
} catch (e: Exception) {
|
||||
lastError = e
|
||||
}
|
||||
}
|
||||
throw (lastError ?: IllegalStateException("All addresses unreachable: $addresses"))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
withContext(Dispatchers.IO) {
|
||||
adbService.disconnect()
|
||||
|
||||
@@ -135,6 +135,10 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
||||
booleanPreferencesKey("hide_simple_config_items"),
|
||||
false,
|
||||
)
|
||||
val PREVIEW_CARD_ON_TOP = Pair(
|
||||
booleanPreferencesKey("preview_card_on_top"),
|
||||
false,
|
||||
)
|
||||
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
|
||||
intPreferencesKey("device_preview_card_height_dp"),
|
||||
1080 / 3,
|
||||
@@ -312,6 +316,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
||||
val lowLatency: Boolean,
|
||||
val fullscreenDebugInfo: Boolean,
|
||||
val hideSimpleConfigItems: Boolean,
|
||||
val previewCardOnTop: Boolean,
|
||||
val devicePreviewCardHeightDp: Int,
|
||||
val realtimeClipboardSyncToDevice: Boolean,
|
||||
|
||||
@@ -375,6 +380,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
||||
bundleField(LOW_LATENCY) { it.lowLatency },
|
||||
bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo },
|
||||
bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems },
|
||||
bundleField(PREVIEW_CARD_ON_TOP) { it.previewCardOnTop },
|
||||
bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { it.devicePreviewCardHeightDp },
|
||||
bundleField(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) { it.realtimeClipboardSyncToDevice },
|
||||
|
||||
@@ -439,6 +445,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
||||
lowLatency = preferences.read(LOW_LATENCY),
|
||||
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
|
||||
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
|
||||
previewCardOnTop = preferences.read(PREVIEW_CARD_ON_TOP),
|
||||
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
|
||||
realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE),
|
||||
|
||||
|
||||
@@ -46,9 +46,9 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||
import io.github.miuzarte.scrcpyforandroid.R
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.ArrowSlider
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||
@@ -72,6 +72,9 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yukonga.miuix.kmp.basic.*
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.AddCircle
|
||||
import top.yukonga.miuix.kmp.icon.extended.Delete
|
||||
import top.yukonga.miuix.kmp.overlay.OverlayDialog
|
||||
import top.yukonga.miuix.kmp.preference.*
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
@@ -1123,6 +1126,7 @@ internal fun DeviceTile(
|
||||
actionEnabled: Boolean,
|
||||
actionInProgress: Boolean,
|
||||
editing: Boolean,
|
||||
connectedAddress: String? = null,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onAction: () -> Unit,
|
||||
@@ -1139,34 +1143,31 @@ internal fun DeviceTile(
|
||||
var originalDraft by remember(editing, device.id) {
|
||||
mutableStateOf(if (editing) device else null)
|
||||
}
|
||||
var draftPortText by remember(editing, device.id) {
|
||||
mutableStateOf(if (editing) device.port.toString() else null)
|
||||
val draftAddresses = remember(editing, device.id) {
|
||||
mutableStateListOf<String>().also {
|
||||
if (editing) it.addAll(device.addresses)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildDraftFromAddresses(): DeviceShortcut {
|
||||
val c = draft ?: return device
|
||||
val cleaned = draftAddresses
|
||||
.map { it.trim().replace(':', ':') }
|
||||
.filter { it.isNotBlank() }
|
||||
return c.copy(addresses = cleaned.ifEmpty { listOf("") })
|
||||
}
|
||||
|
||||
LaunchedEffect(editing, draft) {
|
||||
val currentDraft = draft ?: return@LaunchedEffect
|
||||
if (!editing) return@LaunchedEffect
|
||||
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||
val trimmedHost = currentDraft.host.trim()
|
||||
if (trimmedHost.isBlank()) return@LaunchedEffect
|
||||
val updated = DeviceShortcut(
|
||||
id = currentDraft.id,
|
||||
name = currentDraft.name.trim(),
|
||||
host = trimmedHost,
|
||||
port = currentDraft.port,
|
||||
startScrcpyOnConnect = currentDraft.startScrcpyOnConnect,
|
||||
openFullscreenOnStart = currentDraft.startScrcpyOnConnect
|
||||
&& currentDraft.openFullscreenOnStart,
|
||||
scrcpyProfileId = currentDraft.scrcpyProfileId,
|
||||
)
|
||||
if (updated != device) {
|
||||
val updated = buildDraftFromAddresses()
|
||||
if (updated != device && updated.host.isNotBlank()) {
|
||||
onEditorSave(updated)
|
||||
}
|
||||
}
|
||||
|
||||
val currentDraft = draft ?: device
|
||||
val currentOriginalDraft = originalDraft ?: device
|
||||
val currentDraftPortText = draftPortText ?: device.port.toString()
|
||||
val profileNames = remember(scrcpyProfilesState.profiles) {
|
||||
scrcpyProfilesState.profiles.map { it.name }
|
||||
}
|
||||
@@ -1229,7 +1230,7 @@ internal fun DeviceTile(
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
"${device.host}:${device.port}",
|
||||
connectedAddress ?: "${device.host}:${device.port}",
|
||||
fontSize = 13.sp,
|
||||
color = colorScheme.onSurfaceVariantSummary,
|
||||
maxLines = 1,
|
||||
@@ -1275,27 +1276,53 @@ internal fun DeviceTile(
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
SuperTextField(
|
||||
value = currentDraft.host,
|
||||
onValueChange = { draft = currentDraft.copy(host = it) },
|
||||
label = stringResource(R.string.label_ip_address),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
SuperTextField(
|
||||
value = currentDraftPortText,
|
||||
onValueChange = {
|
||||
draftPortText = it.filter(Char::isDigit)
|
||||
draft = currentDraft.copy(
|
||||
port = draftPortText?.toIntOrNull() ?: Defaults.ADB_PORT,
|
||||
)
|
||||
},
|
||||
label = stringResource(R.string.label_port),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
draftAddresses.forEachIndexed { index, addr ->
|
||||
SuperTextField(
|
||||
value = addr,
|
||||
onValueChange = {
|
||||
draftAddresses[index] = it
|
||||
draft = (draft ?: device).copy(addresses = draftAddresses.toList())
|
||||
},
|
||||
label = stringResource(R.string.label_ip_port),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onFocusLost = {
|
||||
draftAddresses[index] = draftAddresses[index].replace(':', ':')
|
||||
},
|
||||
trailingIcon = {
|
||||
Row(
|
||||
modifier = Modifier.padding(end = UiSpacing.Medium),
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptic.contextClick()
|
||||
draftAddresses.add(index + 1, "")
|
||||
draft = (draft ?: device).copy(addresses = draftAddresses.toList())
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.AddCircle,
|
||||
contentDescription = stringResource(R.string.cd_add_address),
|
||||
)
|
||||
}
|
||||
if (draftAddresses.size > 1) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptic.contextClick()
|
||||
draftAddresses.removeAt(index)
|
||||
draft = (draft ?: device).copy(addresses = draftAddresses.toList())
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Delete,
|
||||
contentDescription = stringResource(R.string.cd_delete_address),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
CheckboxPreference(
|
||||
title = stringResource(R.string.device_config_start_immediately),
|
||||
checkboxLocation = CheckboxLocation.End,
|
||||
@@ -1374,16 +1401,16 @@ internal fun DeviceTileList(
|
||||
actionEnabled: Boolean,
|
||||
actionInProgress: (DeviceShortcut) -> Boolean,
|
||||
editingDeviceId: String?,
|
||||
currentTarget: ConnectionTarget? = null,
|
||||
onClick: (DeviceShortcut) -> Unit,
|
||||
onLongClick: (DeviceShortcut) -> Unit,
|
||||
onAction: (DeviceShortcut) -> Unit,
|
||||
onEditorSave: (DeviceShortcut, DeviceShortcut) -> Unit,
|
||||
onEditorDelete: (DeviceShortcut) -> Unit,
|
||||
onEditorCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
||||
) {
|
||||
devices.forEach { device ->
|
||||
@@ -1393,6 +1420,10 @@ internal fun DeviceTileList(
|
||||
actionEnabled = actionEnabled,
|
||||
actionInProgress = actionInProgress(device),
|
||||
editing = editingDeviceId == device.id,
|
||||
connectedAddress = currentTarget?.let {
|
||||
if (device.matchesAddress(it)) it.toString()
|
||||
else null
|
||||
},
|
||||
onClick = { onClick(device) },
|
||||
onLongClick = { onLongClick(device) },
|
||||
onAction = { onAction(device) },
|
||||
|
||||
@@ -43,7 +43,6 @@ import top.yukonga.miuix.kmp.icon.extended.More
|
||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import kotlin.ranges.coerceAtLeast
|
||||
import kotlin.ranges.coerceIn
|
||||
|
||||
enum class VirtualButtonAction(
|
||||
val id: String,
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
<string name="button_swap">交换</string>
|
||||
<string name="label_ip_address">IP 地址</string>
|
||||
<string name="label_port">端口</string>
|
||||
<string name="label_ip_port">IP:端口</string>
|
||||
<string name="cd_add_address">添加地址</string>
|
||||
<string name="cd_delete_address">删除地址</string>
|
||||
<string name="label_device_name">设备名</string>
|
||||
<string name="label_name">名称</string>
|
||||
<string name="label_wlan_pairing_code">WLAN 配对码</string>
|
||||
@@ -169,6 +172,8 @@
|
||||
<string name="pref_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string>
|
||||
<string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string>
|
||||
<string name="pref_summary_hide_simple_settings">启用后设备页仅保留更多参数、所有应用、最近任务和启动/停止按钮</string>
|
||||
<string name="pref_title_preview_card_on_top">使预览卡显示在配置卡上方</string>
|
||||
<string name="pref_summary_preview_card_on_top">启用后设备页预览卡和虚拟按钮将显示在 Scrcpy 配置卡的上方</string>
|
||||
<string name="pref_title_preview_card_height">预览卡高度</string>
|
||||
<string name="pref_summary_preview_card_height">设备页预览卡高度</string>
|
||||
<string name="pref_title_quick_device_sort">快速设备排序</string>
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
<string name="button_swap">Swap</string>
|
||||
<string name="label_ip_address">IP address</string>
|
||||
<string name="label_port">Port</string>
|
||||
<string name="label_ip_port">IP:Port</string>
|
||||
<string name="cd_add_address">Add address</string>
|
||||
<string name="cd_delete_address">Delete address</string>
|
||||
<string name="label_device_name">Device name</string>
|
||||
<string name="label_name">Name</string>
|
||||
<string name="label_wlan_pairing_code">WLAN pairing code</string>
|
||||
@@ -169,6 +172,8 @@
|
||||
<string name="pref_summary_debug_info">Display resolution, frame rate, and touch info as overlay in fullscreen</string>
|
||||
<string name="pref_title_hide_simple_settings">Hide simple settings on device page</string>
|
||||
<string name="pref_summary_hide_simple_settings">When enabled, the device page only keeps More parameters, All apps, Recent tasks, and Start/Stop buttons</string>
|
||||
<string name="pref_title_preview_card_on_top">Show preview card above config</string>
|
||||
<string name="pref_summary_preview_card_on_top">When enabled, the preview card and virtual buttons appear above the Scrcpy config card on the device page</string>
|
||||
<string name="pref_title_preview_card_height">Preview card height</string>
|
||||
<string name="pref_summary_preview_card_height">Preview card height on device page</string>
|
||||
<string name="pref_title_quick_device_sort">Quick device sort</string>
|
||||
|
||||
Reference in New Issue
Block a user