feat: multiple IPs to one device (#30)

- 新增: 设备支持多个 IP, 自动选择延迟最低的路径
- 新增: 允许让预览卡显示在配置卡上方
This commit is contained in:
謬紗特
2026-05-31 21:08:51 +08:00
committed by GitHub
parent 56c724d282
commit e7f0d09688
17 changed files with 364 additions and 216 deletions

View File

@@ -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)

View File

@@ -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 }
}
}

View File

@@ -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() }
}
}
}

View File

@@ -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,

View File

@@ -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),

View File

@@ -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)
}

View File

@@ -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
}
},

View File

@@ -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)

View File

@@ -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()

View File

@@ -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),

View File

@@ -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) },

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>