diff --git a/CHANGELOG.md b/CHANGELOG.md index ed57cce..3978888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - 依赖: 同步 submodule/miuix - 改进: 补全 haptic feedback - 新增: 设备支持多个 IP, 自动选择延迟最低的路径 -- 新增: 主页允许调整卡片顺序 +- 新增: 允许让预览卡显示在配置卡上方 ## 0.4.0 @@ -23,7 +23,7 @@ ## 0.3.2 -- 修复: 部分设备 (ColorOS) 在拉起输入法后,返回事件会经由输入法最终被发往受控机 +- 修复: 部分设备 (ColorOS) 在拉起输入法后, 返回事件会经由输入法最终被发往受控机 ## 0.3.1 @@ -79,7 +79,7 @@ ## 0.2.2 - 改进: 设备页连接设备后选项卡显示当前配置名 -- 修复: 正确实现 `--disable-screensaver`,`FLAG_KEEP_SCREEN_ON` 而非 `PARTIAL_WAKE_LOCK` +- 修复: 正确实现 `--disable-screensaver`, `FLAG_KEEP_SCREEN_ON` 而非 `PARTIAL_WAKE_LOCK` - 新增: `--record`, `--record-format` - 重构: adb / scrcpy 连接断开逻辑、状态管理 - 修复: 更新不再会重置自定义增加的 Scrcpy 参数配置档 @@ -98,7 +98,7 @@ - 新增: 流式终端界面(支持在应用内直接操作终端) - 新增: 文件管理界面 -- 新增: 原生应用设置页跳转,引导调整后台电池策略 +- 新增: 原生应用设置页跳转, 引导调整后台电池策略 - 改进: Dock 导航交互与可用性 ## 0.1.9 @@ -120,7 +120,7 @@ ## 0.1.6 - 修复: 输入法双重 padding 导致的布局问题 -- 重构: UI 焕新(TopAppBar 与 Dock 视觉重做,交互与页面样式统一) +- 重构: UI 焕新(TopAppBar 与 Dock 视觉重做, 交互与页面样式统一) ## 0.1.5 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f555116..1e256d2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index 89766b0..a312e54 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -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): List 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>(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): List 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): List 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): List 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): List 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): List 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 = 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 = 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 } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index f31ca3e..423695f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -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() } + } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt index a218e56..09e9c97 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt @@ -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): ConnectionTarget { + return connectionController.connectAddresses(addresses, ADB_CONNECT_TIMEOUT_MS, ADB_TCP_PROBE_TIMEOUT_MS) + } + + suspend fun disconnectCurrentTargetBeforeConnectingAny(addresses: List) { + 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, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt index ef127aa..0e2f52d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -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), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionController.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionController.kt index e980f82..a070118 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionController.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/ConnectionController.kt @@ -69,6 +69,31 @@ internal class ConnectionController( adbCoordinator.connectWithTimeout(host, port, timeoutMs) } + suspend fun connectAddresses( + addresses: List, + timeoutMs: Long, + probeTimeoutMs: Int, + ): ConnectionTarget { + return adbCoordinator.connectFirstReachable(addresses, timeoutMs, probeTimeoutMs) + } + + suspend fun disconnectCurrentTargetBeforeConnectingAny( + addresses: List, + ): 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) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt index ce9077f..1af2e84 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbAutoReconnectManager.kt @@ -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 } }, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt index 21f12f2..0b2ad49 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbBackgroundRunner.kt @@ -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?, 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) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt index 1e1881d..a7acfca 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceAdbConnectionCoordinator.kt @@ -36,6 +36,42 @@ internal class DeviceAdbConnectionCoordinator( } } + suspend fun connectFirstReachable( + addresses: List, + 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() diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index 9a3068b..1575258 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -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), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index c71d341..8d4c281 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -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().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) }, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt index 608e0af..94b10f3 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -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, diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 7c19ba3..ed85381 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -59,6 +59,9 @@ 交换 IP 地址 端口 + IP:端口 + 添加地址 + 删除地址 设备名 名称 WLAN 配对码 @@ -169,6 +172,8 @@ 在全屏界面悬浮显示分辨率、帧率和触点信息 设备页隐藏简单设置项 启用后设备页仅保留更多参数、所有应用、最近任务和启动/停止按钮 + 使预览卡显示在配置卡上方 + 启用后设备页预览卡和虚拟按钮将显示在 Scrcpy 配置卡的上方 预览卡高度 设备页预览卡高度 快速设备排序 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a3c2076..9c506b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,6 +59,9 @@ Swap IP address Port + IP:Port + Add address + Delete address Device name Name WLAN pairing code @@ -169,6 +172,8 @@ Display resolution, frame rate, and touch info as overlay in fullscreen Hide simple settings on device page When enabled, the device page only keeps More parameters, All apps, Recent tasks, and Start/Stop buttons + Show preview card above config + When enabled, the preview card and virtual buttons appear above the Scrcpy config card on the device page Preview card height Preview card height on device page Quick device sort diff --git a/build.gradle.kts b/build.gradle.kts index b2c8728..b17d541 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.kotlin.serialization) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1bf356e..8a5c797 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ boringssl = "20251124" conscryptAndroid = "2.5.3" datastorePreferences = "1.2.1" kotlin = "2.3.20" +kotlinx-serialization = "1.11.0" coreKtx = "1.18.0" libcxx = "29.0.14206865" lifecycleRuntimeKtx = "2.10.0" @@ -58,9 +59,11 @@ androidx-core-pip = { group = "androidx.core", name = "core-pip", version.ref = androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidxBiometric" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "androidxSecurityCrypto" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }