feat: multiple IPs to one device (#30)
- 新增: 设备支持多个 IP, 自动选择延迟最低的路径 - 新增: 允许让预览卡显示在配置卡上方
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -5,7 +5,7 @@
|
|||||||
- 依赖: 同步 submodule/miuix
|
- 依赖: 同步 submodule/miuix
|
||||||
- 改进: 补全 haptic feedback
|
- 改进: 补全 haptic feedback
|
||||||
- 新增: 设备支持多个 IP, 自动选择延迟最低的路径
|
- 新增: 设备支持多个 IP, 自动选择延迟最低的路径
|
||||||
- 新增: 主页允许调整卡片顺序
|
- 新增: 允许让预览卡显示在配置卡上方
|
||||||
|
|
||||||
## 0.4.0
|
## 0.4.0
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
## 0.3.2
|
## 0.3.2
|
||||||
|
|
||||||
- 修复: 部分设备 (ColorOS) 在拉起输入法后,返回事件会经由输入法最终被发往受控机
|
- 修复: 部分设备 (ColorOS) 在拉起输入法后, 返回事件会经由输入法最终被发往受控机
|
||||||
|
|
||||||
## 0.3.1
|
## 0.3.1
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
## 0.2.2
|
## 0.2.2
|
||||||
|
|
||||||
- 改进: 设备页连接设备后选项卡显示当前配置名
|
- 改进: 设备页连接设备后选项卡显示当前配置名
|
||||||
- 修复: 正确实现 `--disable-screensaver`,`FLAG_KEEP_SCREEN_ON` 而非 `PARTIAL_WAKE_LOCK`
|
- 修复: 正确实现 `--disable-screensaver`, `FLAG_KEEP_SCREEN_ON` 而非 `PARTIAL_WAKE_LOCK`
|
||||||
- 新增: `--record`, `--record-format`
|
- 新增: `--record`, `--record-format`
|
||||||
- 重构: adb / scrcpy 连接断开逻辑、状态管理
|
- 重构: adb / scrcpy 连接断开逻辑、状态管理
|
||||||
- 修复: 更新不再会重置自定义增加的 Scrcpy 参数配置档
|
- 修复: 更新不再会重置自定义增加的 Scrcpy 参数配置档
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
- 新增: 流式终端界面(支持在应用内直接操作终端)
|
- 新增: 流式终端界面(支持在应用内直接操作终端)
|
||||||
- 新增: 文件管理界面
|
- 新增: 文件管理界面
|
||||||
- 新增: 原生应用设置页跳转,引导调整后台电池策略
|
- 新增: 原生应用设置页跳转, 引导调整后台电池策略
|
||||||
- 改进: Dock 导航交互与可用性
|
- 改进: Dock 导航交互与可用性
|
||||||
|
|
||||||
## 0.1.9
|
## 0.1.9
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
## 0.1.6
|
## 0.1.6
|
||||||
|
|
||||||
- 修复: 输入法双重 padding 导致的布局问题
|
- 修复: 输入法双重 padding 导致的布局问题
|
||||||
- 重构: UI 焕新(TopAppBar 与 Dock 视觉重做,交互与页面样式统一)
|
- 重构: UI 焕新(TopAppBar 与 Dock 视觉重做, 交互与页面样式统一)
|
||||||
|
|
||||||
## 0.1.5
|
## 0.1.5
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.util.Properties
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +151,7 @@ dependencies {
|
|||||||
implementation(libs.bcpkix.jdk18on)
|
implementation(libs.bcpkix.jdk18on)
|
||||||
implementation(libs.conscrypt.android)
|
implementation(libs.conscrypt.android)
|
||||||
implementation(libs.reorderable)
|
implementation(libs.reorderable)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.compose.runtime)
|
implementation(libs.androidx.compose.runtime)
|
||||||
implementation(libs.androidx.biometric)
|
implementation(libs.androidx.biometric)
|
||||||
|
|||||||
@@ -4,42 +4,106 @@ import android.os.Parcelable
|
|||||||
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
||||||
import kotlinx.parcelize.Parcelize
|
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 {
|
class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> by devices {
|
||||||
fun marshalToString(
|
fun marshalToString(): String = DeviceShortcut.json.encodeToString(devices)
|
||||||
separator: String = DEFAULT_SEPARATOR,
|
|
||||||
): String = joinToString(separator) { it.marshalToString() }
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_SEPARATOR = "\n"
|
fun unmarshalFrom(s: String): DeviceShortcuts {
|
||||||
|
|
||||||
fun unmarshalFrom(
|
|
||||||
s: String,
|
|
||||||
separator: String = DEFAULT_SEPARATOR,
|
|
||||||
): DeviceShortcuts {
|
|
||||||
if (s.isBlank()) return DeviceShortcuts(emptyList())
|
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
|
var nextLegacyId = 1
|
||||||
val list = s.splitToSequence(separator)
|
val list = s.lines()
|
||||||
.mapNotNull { raw ->
|
.mapNotNull { raw ->
|
||||||
val firstValue = raw.split(DeviceShortcut.DEFAULT_SEPARATOR, limit = 2)
|
val firstValue = raw.split("|", limit = 2)
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
?.trim()
|
?.trim()
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
if (firstValue.toIntOrNull() != null) {
|
if (firstValue.toIntOrNull() != null) {
|
||||||
DeviceShortcut.unmarshalFrom(raw)
|
unmarshalDeviceFromPipe(raw)
|
||||||
} else {
|
} else {
|
||||||
DeviceShortcut.unmarshalFrom(
|
unmarshalDeviceFromPipe(
|
||||||
s = raw,
|
raw,
|
||||||
fallbackId = nextLegacyId.toString(),
|
fallbackId = nextLegacyId.toString(),
|
||||||
).also {
|
).also {
|
||||||
if (it != null) nextLegacyId++
|
if (it != null) nextLegacyId++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toList()
|
|
||||||
return DeviceShortcuts(list)
|
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 }
|
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 old = devices[idx]
|
||||||
val updateById = id != null
|
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(
|
val updated = DeviceShortcut(
|
||||||
id = old.id,
|
id = old.id,
|
||||||
name = when {
|
name = when {
|
||||||
@@ -78,14 +161,12 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
|
|||||||
updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name
|
updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name
|
||||||
else -> name
|
else -> name
|
||||||
},
|
},
|
||||||
host = if (updateById) host ?: old.host else old.host,
|
addresses = updatedAddresses,
|
||||||
port = if (updateById) port ?: old.port else newPort ?: old.port,
|
|
||||||
startScrcpyOnConnect = startScrcpyOnConnect ?: old.startScrcpyOnConnect,
|
startScrcpyOnConnect = startScrcpyOnConnect ?: old.startScrcpyOnConnect,
|
||||||
openFullscreenOnStart = openFullscreenOnStart ?: old.openFullscreenOnStart,
|
openFullscreenOnStart = openFullscreenOnStart ?: old.openFullscreenOnStart,
|
||||||
scrcpyProfileId = scrcpyProfileId ?: old.scrcpyProfileId,
|
scrcpyProfileId = scrcpyProfileId ?: old.scrcpyProfileId,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 若无任何变化,返回原实例
|
|
||||||
if (updated == old) return this
|
if (updated == old) return this
|
||||||
|
|
||||||
val newList = devices.toMutableList()
|
val newList = devices.toMutableList()
|
||||||
@@ -93,7 +174,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
|
|||||||
this[idx] = updated
|
this[idx] = updated
|
||||||
}
|
}
|
||||||
return DeviceShortcuts(
|
return DeviceShortcuts(
|
||||||
if ((updateById && (updated.host != old.host || updated.port != old.port))
|
if ((updateById && (updated.addresses != old.addresses))
|
||||||
|| (newPort != null && newPort != old.port)
|
|| (newPort != null && newPort != old.port)
|
||||||
)
|
)
|
||||||
newList.distinctBy { it.id }
|
newList.distinctBy { it.id }
|
||||||
@@ -114,7 +195,6 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
|
|||||||
}
|
}
|
||||||
val newList = devices.toMutableList()
|
val newList = devices.toMutableList()
|
||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
// Keep existing id stable when matching by host:port.
|
|
||||||
val existingId = devices[existingIdx].id
|
val existingId = devices[existingIdx].id
|
||||||
newList[existingIdx] = normalizedShortcut.copy(id = existingId)
|
newList[existingIdx] = normalizedShortcut.copy(id = existingId)
|
||||||
} else {
|
} else {
|
||||||
@@ -139,119 +219,51 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
|
|||||||
if (fromIndex == toIndex) return this
|
if (fromIndex == toIndex) return this
|
||||||
val mutable = devices.toMutableList()
|
val mutable = devices.toMutableList()
|
||||||
val item = mutable.removeAt(fromIndex)
|
val item = mutable.removeAt(fromIndex)
|
||||||
// 如果目标位置在原位置之后,移除后列表长度减1,因此目标索引需减1
|
|
||||||
val target = if (toIndex > fromIndex) toIndex - 1 else toIndex
|
val target = if (toIndex > fromIndex) toIndex - 1 else toIndex
|
||||||
mutable.add(target, item)
|
mutable.add(target, item)
|
||||||
return DeviceShortcuts(mutable)
|
return DeviceShortcuts(mutable)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除指定设备
|
|
||||||
fun remove(id: String) = DeviceShortcuts(devices.filterNot { it.id == id })
|
fun remove(id: String) = DeviceShortcuts(devices.filterNot { it.id == id })
|
||||||
|
|
||||||
// 清空所有设备
|
|
||||||
fun clear() = DeviceShortcuts(emptyList())
|
fun clear() = DeviceShortcuts(emptyList())
|
||||||
|
|
||||||
// 复制当前实例
|
|
||||||
fun copy(devices: List<DeviceShortcut> = this.devices): DeviceShortcuts =
|
fun copy(devices: List<DeviceShortcut> = this.devices): DeviceShortcuts =
|
||||||
DeviceShortcuts(devices)
|
DeviceShortcuts(devices)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class DeviceShortcut(
|
data class DeviceShortcut(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val host: String,
|
val addresses: List<String> = listOf(""),
|
||||||
val port: Int = Defaults.ADB_PORT,
|
|
||||||
val startScrcpyOnConnect: Boolean = false,
|
val startScrcpyOnConnect: Boolean = false,
|
||||||
val openFullscreenOnStart: Boolean = false,
|
val openFullscreenOnStart: Boolean = false,
|
||||||
val scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
val scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||||
) {
|
) {
|
||||||
fun marshalToString(
|
val host: String
|
||||||
separator: String = DEFAULT_SEPARATOR,
|
get() {
|
||||||
): String = listOf(
|
val first = addresses.firstOrNull() ?: return ""
|
||||||
id.trim(),
|
return ConnectionTarget.unmarshalFrom(first)?.host ?: ""
|
||||||
name.trim(),
|
}
|
||||||
host.trim(),
|
|
||||||
port.toString(),
|
val port: Int
|
||||||
if (startScrcpyOnConnect) "1" else "0",
|
get() {
|
||||||
if (openFullscreenOnStart) "1" else "0",
|
val first = addresses.firstOrNull() ?: return Defaults.ADB_PORT
|
||||||
scrcpyProfileId.trim(),
|
return ConnectionTarget.unmarshalFrom(first)?.port ?: Defaults.ADB_PORT
|
||||||
).joinToString(
|
}
|
||||||
separator = separator,
|
|
||||||
)
|
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 {
|
companion object {
|
||||||
const val DEFAULT_SEPARATOR = "|"
|
val json = Json { ignoreUnknownKeys = true }
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -317,10 +317,10 @@ internal fun DeviceTabPage(
|
|||||||
fun DeviceListSection() {
|
fun DeviceListSection() {
|
||||||
DeviceTileList(
|
DeviceTileList(
|
||||||
devices = savedShortcuts,
|
devices = savedShortcuts,
|
||||||
|
currentTarget = currentTarget,
|
||||||
isConnected = { device ->
|
isConnected = { device ->
|
||||||
adbConnected
|
adbConnected
|
||||||
&& currentTarget?.host == device.host
|
&& currentTarget?.let { device.matchesAddress(it) } == true
|
||||||
&& currentTarget?.port == device.port
|
|
||||||
},
|
},
|
||||||
actionEnabled = !busy && !adbConnecting,
|
actionEnabled = !busy && !adbConnecting,
|
||||||
actionInProgress = { device -> adbConnecting && activeDeviceActionId == device.id },
|
actionInProgress = { device -> adbConnecting && activeDeviceActionId == device.id },
|
||||||
@@ -331,8 +331,7 @@ internal fun DeviceTabPage(
|
|||||||
},
|
},
|
||||||
onLongClick = { device ->
|
onLongClick = { device ->
|
||||||
val connected = adbConnected
|
val connected = adbConnected
|
||||||
&& currentTarget?.host == device.host
|
&& currentTarget?.let { device.matchesAddress(it) } == true
|
||||||
&& currentTarget?.port == device.port
|
|
||||||
if (connected) {
|
if (connected) {
|
||||||
AppRuntime.snackbar(R.string.device_cannot_modify_connected)
|
AppRuntime.snackbar(R.string.device_cannot_modify_connected)
|
||||||
} else {
|
} else {
|
||||||
@@ -347,16 +346,8 @@ internal fun DeviceTabPage(
|
|||||||
if (editingDeviceId == device.id) viewModel.setEditingDeviceId(null)
|
if (editingDeviceId == device.id) viewModel.setEditingDeviceId(null)
|
||||||
viewModel.onDeviceAction(device)
|
viewModel.onDeviceAction(device)
|
||||||
},
|
},
|
||||||
onEditorSave = { device, updated ->
|
onEditorSave = { _, updated ->
|
||||||
viewModel.updateShortcut(
|
viewModel.upsertShortcut(updated)
|
||||||
id = device.id,
|
|
||||||
name = updated.name,
|
|
||||||
host = updated.host,
|
|
||||||
port = updated.port,
|
|
||||||
startScrcpyOnConnect = updated.startScrcpyOnConnect,
|
|
||||||
openFullscreenOnStart = updated.openFullscreenOnStart,
|
|
||||||
scrcpyProfileId = updated.scrcpyProfileId,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onEditorDelete = { device ->
|
onEditorDelete = { device ->
|
||||||
viewModel.removeShortcut(device.id)
|
viewModel.removeShortcut(device.id)
|
||||||
@@ -376,7 +367,7 @@ internal fun DeviceTabPage(
|
|||||||
onAddDevice = {
|
onAddDevice = {
|
||||||
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
|
val target = ConnectionTarget.unmarshalFrom(quickConnectInputTemp)
|
||||||
?: return@QuickConnectCard
|
?: 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)
|
AppRuntime.snackbar(R.string.device_added, target.host, target.port)
|
||||||
},
|
},
|
||||||
onConnect = {
|
onConnect = {
|
||||||
@@ -400,7 +391,6 @@ internal fun DeviceTabPage(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ScrcpyConfigSection() {
|
fun ScrcpyConfigSection() {
|
||||||
SectionSmallTitle("Scrcpy")
|
|
||||||
ConfigPanel(
|
ConfigPanel(
|
||||||
busy = busy,
|
busy = busy,
|
||||||
activeProfileId = connectedScrcpyProfileId,
|
activeProfileId = connectedScrcpyProfileId,
|
||||||
@@ -511,7 +501,6 @@ internal fun DeviceTabPage(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ScrcpyConfigSectionForTwoPane() {
|
fun ScrcpyConfigSectionForTwoPane() {
|
||||||
SectionSmallTitle("Scrcpy")
|
|
||||||
ConfigPanel(
|
ConfigPanel(
|
||||||
busy = busy,
|
busy = busy,
|
||||||
activeProfileId = connectedScrcpyProfileId,
|
activeProfileId = connectedScrcpyProfileId,
|
||||||
@@ -619,14 +608,26 @@ internal fun DeviceTabPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (adbConnected) {
|
if (adbConnected) {
|
||||||
item {
|
if (includeInlinePreviewControls && canShowPreviewControls && asBundle.previewCardOnTop) {
|
||||||
if (useTwoPaneConfigPanel) ScrcpyConfigSectionForTwoPane()
|
item(key = PREVIEW_CARD_ITEM_KEY) {
|
||||||
else ScrcpyConfigSection()
|
SectionSmallTitle(stringResource(R.string.device_section_scrcpy))
|
||||||
}
|
PreviewSection()
|
||||||
|
}
|
||||||
if (includeInlinePreviewControls && canShowPreviewControls) {
|
|
||||||
item(key = PREVIEW_CARD_ITEM_KEY) { PreviewSection() }
|
|
||||||
item { VirtualButtonsSection() }
|
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) {
|
suspend fun disconnectCurrentTargetBeforeConnecting(newHost: String, newPort: Int) {
|
||||||
val disconnected =
|
val disconnected = connectionController.disconnectCurrentTargetBeforeConnecting(newHost, newPort)
|
||||||
connectionController.disconnectCurrentTargetBeforeConnecting(newHost, newPort) ?: return
|
?: return
|
||||||
sessionReconnectBlacklistHosts += disconnected.host
|
sessionReconnectBlacklistHosts += disconnected.host
|
||||||
if (disconnected.host.isNotBlank())
|
if (disconnected.host.isNotBlank())
|
||||||
_savedShortcuts.update { it.update(host = disconnected.host, port = disconnected.port) }
|
_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)
|
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) {
|
fun applyConnectedDeviceCapabilities(sdkInt: Int) {
|
||||||
connectionController.applyConnectedDeviceCapabilities(sdkInt)
|
connectionController.applyConnectedDeviceCapabilities(sdkInt)
|
||||||
}
|
}
|
||||||
@@ -710,8 +720,7 @@ internal class DeviceTabViewModel(
|
|||||||
|
|
||||||
fun onDeviceAction(device: DeviceShortcut) {
|
fun onDeviceAction(device: DeviceShortcut) {
|
||||||
val connected = adbConnected.value
|
val connected = adbConnected.value
|
||||||
&& currentTarget.value?.host == device.host
|
&& currentTarget.value?.let { device.matchesAddress(it) } == true
|
||||||
&& currentTarget.value?.port == device.port
|
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
runAdbConnect(
|
runAdbConnect(
|
||||||
@@ -719,12 +728,12 @@ internal class DeviceTabViewModel(
|
|||||||
onStarted = { _activeDeviceActionId.value = device.id },
|
onStarted = { _activeDeviceActionId.value = device.id },
|
||||||
onFinished = { _activeDeviceActionId.value = null },
|
onFinished = { _activeDeviceActionId.value = null },
|
||||||
) {
|
) {
|
||||||
disconnectCurrentTargetBeforeConnecting(device.host, device.port)
|
disconnectCurrentTargetBeforeConnectingAny(device.addresses)
|
||||||
try {
|
try {
|
||||||
connectWithTimeout(device.host, device.port)
|
val matched = connectAddresses(device.addresses)
|
||||||
handleAdbConnected(
|
handleAdbConnected(
|
||||||
host = device.host,
|
host = matched.host,
|
||||||
port = device.port,
|
port = matched.port,
|
||||||
autoStartScrcpy = device.startScrcpyOnConnect,
|
autoStartScrcpy = device.startScrcpyOnConnect,
|
||||||
autoEnterFullScreen = device.startScrcpyOnConnect && device.openFullscreenOnStart,
|
autoEnterFullScreen = device.startScrcpyOnConnect && device.openFullscreenOnStart,
|
||||||
scrcpyProfileId = device.scrcpyProfileId,
|
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(
|
ArrowSlider(
|
||||||
title = stringResource(R.string.pref_title_preview_card_height),
|
title = stringResource(R.string.pref_title_preview_card_height),
|
||||||
summary = stringResource(R.string.pref_summary_preview_card_height),
|
summary = stringResource(R.string.pref_summary_preview_card_height),
|
||||||
|
|||||||
@@ -69,6 +69,31 @@ internal class ConnectionController(
|
|||||||
adbCoordinator.connectWithTimeout(host, port, timeoutMs)
|
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 {
|
suspend fun keepAliveCheck(timeoutMs: Long): Boolean {
|
||||||
return adbCoordinator.isConnected(timeoutMs)
|
return adbCoordinator.isConnected(timeoutMs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,16 +70,16 @@ internal class DeviceAdbAutoReconnectManager(
|
|||||||
},
|
},
|
||||||
discoverConnectService = discoverConnectService,
|
discoverConnectService = discoverConnectService,
|
||||||
onMdnsPortChanged = onMdnsPortChanged,
|
onMdnsPortChanged = onMdnsPortChanged,
|
||||||
connectKnownShortcut = { target ->
|
connectKnownShortcut = { device, addressTarget ->
|
||||||
if (!controller.runAutoAdbConnect(target.host, target.port, connectTimeoutMs)) {
|
if (!controller.runAutoAdbConnect(addressTarget.host, addressTarget.port, connectTimeoutMs)) {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
controller.handleAdbConnected(
|
controller.handleAdbConnected(
|
||||||
host = target.host,
|
host = addressTarget.host,
|
||||||
port = target.port,
|
port = addressTarget.port,
|
||||||
scrcpyProfileId = target.scrcpyProfileId,
|
scrcpyProfileId = device.scrcpyProfileId,
|
||||||
)
|
)
|
||||||
onKnownDeviceReconnected(target)
|
onKnownDeviceReconnected(device)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.services
|
package io.github.miuzarte.scrcpyforandroid.services
|
||||||
|
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
@@ -64,7 +65,7 @@ internal class DeviceAdbBackgroundRunner: Closeable {
|
|||||||
probeTcpReachable: suspend (host: String, port: Int) -> Boolean,
|
probeTcpReachable: suspend (host: String, port: Int) -> Boolean,
|
||||||
discoverConnectService: suspend () -> Pair<String, Int>?,
|
discoverConnectService: suspend () -> Pair<String, Int>?,
|
||||||
onMdnsPortChanged: suspend (host: String, oldPort: Int, newPort: Int) -> Unit,
|
onMdnsPortChanged: suspend (host: String, oldPort: Int, newPort: Int) -> Unit,
|
||||||
connectKnownShortcut: suspend (shortcut: DeviceShortcut) -> Boolean,
|
connectKnownShortcut: suspend (DeviceShortcut, ConnectionTarget) -> Boolean,
|
||||||
connectDiscoveredShortcut: suspend (
|
connectDiscoveredShortcut: suspend (
|
||||||
host: String,
|
host: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
@@ -81,18 +82,17 @@ internal class DeviceAdbBackgroundRunner: Closeable {
|
|||||||
|
|
||||||
val quickCandidates = savedShortcuts()
|
val quickCandidates = savedShortcuts()
|
||||||
if (quickCandidates.isNotEmpty()) {
|
if (quickCandidates.isNotEmpty()) {
|
||||||
for (target in quickCandidates) {
|
for (device in quickCandidates) {
|
||||||
if (isConnected() || isAdbConnecting()) break
|
if (isConnected() || isAdbConnecting()) break
|
||||||
if (isBlacklisted(target.host)) continue
|
for (addr in device.addresses) {
|
||||||
|
if (isConnected() || isAdbConnecting()) break
|
||||||
val targetKey = "${target.host}:${target.port}"
|
val target = ConnectionTarget.unmarshalFrom(addr) ?: continue
|
||||||
if (quickConnectTriedOnce.contains(targetKey)) continue
|
if (isBlacklisted(target.host)) continue
|
||||||
|
val targetKey = "${target.host}:${target.port}"
|
||||||
if (!probeTcpReachable(target.host, target.port)) continue
|
if (quickConnectTriedOnce.contains(targetKey)) continue
|
||||||
quickConnectTriedOnce += targetKey
|
if (!probeTcpReachable(target.host, target.port)) continue
|
||||||
|
quickConnectTriedOnce += targetKey
|
||||||
if (connectKnownShortcut(target)) {
|
if (connectKnownShortcut(device, target)) break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isConnected()) break
|
if (isConnected()) break
|
||||||
@@ -110,17 +110,21 @@ internal class DeviceAdbBackgroundRunner: Closeable {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val knownDevice = savedShortcuts().firstOrNull { it.host == discoveredHost }
|
val knownDevice = savedShortcuts().firstOrNull { it.matchesHost(discoveredHost) }
|
||||||
if (knownDevice == null) {
|
if (knownDevice == null) {
|
||||||
delay(retryIntervalMs)
|
delay(retryIntervalMs)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val portToReplace = savedShortcuts().firstOrNull {
|
val portToReplace = savedShortcuts()
|
||||||
it.host == discoveredHost &&
|
.filter { it != knownDevice }
|
||||||
it.port != knownDevice.port &&
|
.firstNotNullOfOrNull { device ->
|
||||||
it.port != discoveredPort
|
device.addresses.firstNotNullOfOrNull { addr ->
|
||||||
}?.port
|
val ct = ConnectionTarget.unmarshalFrom(addr)
|
||||||
|
if (ct != null && ct.host == discoveredHost && ct.port != discoveredPort) ct.port
|
||||||
|
else null
|
||||||
|
}
|
||||||
|
}
|
||||||
if (portToReplace != null) {
|
if (portToReplace != null) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
onMdnsPortChanged(discoveredHost, portToReplace, discoveredPort)
|
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() {
|
suspend fun disconnect() {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
adbService.disconnect()
|
adbService.disconnect()
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
|||||||
booleanPreferencesKey("hide_simple_config_items"),
|
booleanPreferencesKey("hide_simple_config_items"),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
val PREVIEW_CARD_ON_TOP = Pair(
|
||||||
|
booleanPreferencesKey("preview_card_on_top"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
|
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
|
||||||
intPreferencesKey("device_preview_card_height_dp"),
|
intPreferencesKey("device_preview_card_height_dp"),
|
||||||
1080 / 3,
|
1080 / 3,
|
||||||
@@ -312,6 +316,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
|||||||
val lowLatency: Boolean,
|
val lowLatency: Boolean,
|
||||||
val fullscreenDebugInfo: Boolean,
|
val fullscreenDebugInfo: Boolean,
|
||||||
val hideSimpleConfigItems: Boolean,
|
val hideSimpleConfigItems: Boolean,
|
||||||
|
val previewCardOnTop: Boolean,
|
||||||
val devicePreviewCardHeightDp: Int,
|
val devicePreviewCardHeightDp: Int,
|
||||||
val realtimeClipboardSyncToDevice: Boolean,
|
val realtimeClipboardSyncToDevice: Boolean,
|
||||||
|
|
||||||
@@ -375,6 +380,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
|||||||
bundleField(LOW_LATENCY) { it.lowLatency },
|
bundleField(LOW_LATENCY) { it.lowLatency },
|
||||||
bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo },
|
bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo },
|
||||||
bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems },
|
bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems },
|
||||||
|
bundleField(PREVIEW_CARD_ON_TOP) { it.previewCardOnTop },
|
||||||
bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { it.devicePreviewCardHeightDp },
|
bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { it.devicePreviewCardHeightDp },
|
||||||
bundleField(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) { it.realtimeClipboardSyncToDevice },
|
bundleField(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) { it.realtimeClipboardSyncToDevice },
|
||||||
|
|
||||||
@@ -439,6 +445,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
|||||||
lowLatency = preferences.read(LOW_LATENCY),
|
lowLatency = preferences.read(LOW_LATENCY),
|
||||||
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
|
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
|
||||||
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
|
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
|
||||||
|
previewCardOnTop = preferences.read(PREVIEW_CARD_ON_TOP),
|
||||||
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
|
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
|
||||||
realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE),
|
realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE),
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||||
import io.github.miuzarte.scrcpyforandroid.R
|
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.ScrcpyPresets
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
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.models.DeviceShortcut
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.ArrowSlider
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.ArrowSlider
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||||
@@ -72,6 +72,9 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import top.yukonga.miuix.kmp.basic.*
|
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.overlay.OverlayDialog
|
||||||
import top.yukonga.miuix.kmp.preference.*
|
import top.yukonga.miuix.kmp.preference.*
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
@@ -1123,6 +1126,7 @@ internal fun DeviceTile(
|
|||||||
actionEnabled: Boolean,
|
actionEnabled: Boolean,
|
||||||
actionInProgress: Boolean,
|
actionInProgress: Boolean,
|
||||||
editing: Boolean,
|
editing: Boolean,
|
||||||
|
connectedAddress: String? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
onAction: () -> Unit,
|
onAction: () -> Unit,
|
||||||
@@ -1139,34 +1143,31 @@ internal fun DeviceTile(
|
|||||||
var originalDraft by remember(editing, device.id) {
|
var originalDraft by remember(editing, device.id) {
|
||||||
mutableStateOf(if (editing) device else null)
|
mutableStateOf(if (editing) device else null)
|
||||||
}
|
}
|
||||||
var draftPortText by remember(editing, device.id) {
|
val draftAddresses = remember(editing, device.id) {
|
||||||
mutableStateOf(if (editing) device.port.toString() else null)
|
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) {
|
LaunchedEffect(editing, draft) {
|
||||||
val currentDraft = draft ?: return@LaunchedEffect
|
|
||||||
if (!editing) return@LaunchedEffect
|
if (!editing) return@LaunchedEffect
|
||||||
delay(Settings.BUNDLE_SAVE_DELAY)
|
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||||
val trimmedHost = currentDraft.host.trim()
|
val updated = buildDraftFromAddresses()
|
||||||
if (trimmedHost.isBlank()) return@LaunchedEffect
|
if (updated != device && updated.host.isNotBlank()) {
|
||||||
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) {
|
|
||||||
onEditorSave(updated)
|
onEditorSave(updated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentDraft = draft ?: device
|
val currentDraft = draft ?: device
|
||||||
val currentOriginalDraft = originalDraft ?: device
|
val currentOriginalDraft = originalDraft ?: device
|
||||||
val currentDraftPortText = draftPortText ?: device.port.toString()
|
|
||||||
val profileNames = remember(scrcpyProfilesState.profiles) {
|
val profileNames = remember(scrcpyProfilesState.profiles) {
|
||||||
scrcpyProfilesState.profiles.map { it.name }
|
scrcpyProfilesState.profiles.map { it.name }
|
||||||
}
|
}
|
||||||
@@ -1229,7 +1230,7 @@ internal fun DeviceTile(
|
|||||||
color = colorScheme.onSurface,
|
color = colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"${device.host}:${device.port}",
|
connectedAddress ?: "${device.host}:${device.port}",
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = colorScheme.onSurfaceVariantSummary,
|
color = colorScheme.onSurfaceVariantSummary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -1275,27 +1276,53 @@ internal fun DeviceTile(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
SuperTextField(
|
draftAddresses.forEachIndexed { index, addr ->
|
||||||
value = currentDraft.host,
|
SuperTextField(
|
||||||
onValueChange = { draft = currentDraft.copy(host = it) },
|
value = addr,
|
||||||
label = stringResource(R.string.label_ip_address),
|
onValueChange = {
|
||||||
singleLine = true,
|
draftAddresses[index] = it
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
draft = (draft ?: device).copy(addresses = draftAddresses.toList())
|
||||||
modifier = Modifier.fillMaxWidth(),
|
},
|
||||||
)
|
label = stringResource(R.string.label_ip_port),
|
||||||
SuperTextField(
|
singleLine = true,
|
||||||
value = currentDraftPortText,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onValueChange = {
|
onFocusLost = {
|
||||||
draftPortText = it.filter(Char::isDigit)
|
draftAddresses[index] = draftAddresses[index].replace(':', ':')
|
||||||
draft = currentDraft.copy(
|
},
|
||||||
port = draftPortText?.toIntOrNull() ?: Defaults.ADB_PORT,
|
trailingIcon = {
|
||||||
)
|
Row(
|
||||||
},
|
modifier = Modifier.padding(end = UiSpacing.Medium),
|
||||||
label = stringResource(R.string.label_port),
|
) {
|
||||||
singleLine = true,
|
IconButton(
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
onClick = {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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(
|
CheckboxPreference(
|
||||||
title = stringResource(R.string.device_config_start_immediately),
|
title = stringResource(R.string.device_config_start_immediately),
|
||||||
checkboxLocation = CheckboxLocation.End,
|
checkboxLocation = CheckboxLocation.End,
|
||||||
@@ -1374,16 +1401,16 @@ internal fun DeviceTileList(
|
|||||||
actionEnabled: Boolean,
|
actionEnabled: Boolean,
|
||||||
actionInProgress: (DeviceShortcut) -> Boolean,
|
actionInProgress: (DeviceShortcut) -> Boolean,
|
||||||
editingDeviceId: String?,
|
editingDeviceId: String?,
|
||||||
|
currentTarget: ConnectionTarget? = null,
|
||||||
onClick: (DeviceShortcut) -> Unit,
|
onClick: (DeviceShortcut) -> Unit,
|
||||||
onLongClick: (DeviceShortcut) -> Unit,
|
onLongClick: (DeviceShortcut) -> Unit,
|
||||||
onAction: (DeviceShortcut) -> Unit,
|
onAction: (DeviceShortcut) -> Unit,
|
||||||
onEditorSave: (DeviceShortcut, DeviceShortcut) -> Unit,
|
onEditorSave: (DeviceShortcut, DeviceShortcut) -> Unit,
|
||||||
onEditorDelete: (DeviceShortcut) -> Unit,
|
onEditorDelete: (DeviceShortcut) -> Unit,
|
||||||
onEditorCancel: () -> Unit,
|
onEditorCancel: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
||||||
) {
|
) {
|
||||||
devices.forEach { device ->
|
devices.forEach { device ->
|
||||||
@@ -1393,6 +1420,10 @@ internal fun DeviceTileList(
|
|||||||
actionEnabled = actionEnabled,
|
actionEnabled = actionEnabled,
|
||||||
actionInProgress = actionInProgress(device),
|
actionInProgress = actionInProgress(device),
|
||||||
editing = editingDeviceId == device.id,
|
editing = editingDeviceId == device.id,
|
||||||
|
connectedAddress = currentTarget?.let {
|
||||||
|
if (device.matchesAddress(it)) it.toString()
|
||||||
|
else null
|
||||||
|
},
|
||||||
onClick = { onClick(device) },
|
onClick = { onClick(device) },
|
||||||
onLongClick = { onLongClick(device) },
|
onLongClick = { onLongClick(device) },
|
||||||
onAction = { onAction(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.overlay.OverlayListPopup
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
import kotlin.ranges.coerceAtLeast
|
import kotlin.ranges.coerceAtLeast
|
||||||
import kotlin.ranges.coerceIn
|
|
||||||
|
|
||||||
enum class VirtualButtonAction(
|
enum class VirtualButtonAction(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
<string name="button_swap">交换</string>
|
<string name="button_swap">交换</string>
|
||||||
<string name="label_ip_address">IP 地址</string>
|
<string name="label_ip_address">IP 地址</string>
|
||||||
<string name="label_port">端口</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_device_name">设备名</string>
|
||||||
<string name="label_name">名称</string>
|
<string name="label_name">名称</string>
|
||||||
<string name="label_wlan_pairing_code">WLAN 配对码</string>
|
<string name="label_wlan_pairing_code">WLAN 配对码</string>
|
||||||
@@ -169,6 +172,8 @@
|
|||||||
<string name="pref_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string>
|
<string name="pref_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string>
|
||||||
<string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string>
|
<string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string>
|
||||||
<string name="pref_summary_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_title_preview_card_height">预览卡高度</string>
|
||||||
<string name="pref_summary_preview_card_height">设备页预览卡高度</string>
|
<string name="pref_summary_preview_card_height">设备页预览卡高度</string>
|
||||||
<string name="pref_title_quick_device_sort">快速设备排序</string>
|
<string name="pref_title_quick_device_sort">快速设备排序</string>
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
<string name="button_swap">Swap</string>
|
<string name="button_swap">Swap</string>
|
||||||
<string name="label_ip_address">IP address</string>
|
<string name="label_ip_address">IP address</string>
|
||||||
<string name="label_port">Port</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_device_name">Device name</string>
|
||||||
<string name="label_name">Name</string>
|
<string name="label_name">Name</string>
|
||||||
<string name="label_wlan_pairing_code">WLAN pairing code</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_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_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_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_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_summary_preview_card_height">Preview card height on device page</string>
|
||||||
<string name="pref_title_quick_device_sort">Quick device sort</string>
|
<string name="pref_title_quick_device_sort">Quick device sort</string>
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ plugins {
|
|||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
alias(libs.plugins.compose.compiler) apply false
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ boringssl = "20251124"
|
|||||||
conscryptAndroid = "2.5.3"
|
conscryptAndroid = "2.5.3"
|
||||||
datastorePreferences = "1.2.1"
|
datastorePreferences = "1.2.1"
|
||||||
kotlin = "2.3.20"
|
kotlin = "2.3.20"
|
||||||
|
kotlinx-serialization = "1.11.0"
|
||||||
coreKtx = "1.18.0"
|
coreKtx = "1.18.0"
|
||||||
libcxx = "29.0.14206865"
|
libcxx = "29.0.14206865"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
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-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidxBiometric" }
|
||||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "androidxSecurityCrypto" }
|
androidx-security-crypto = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "androidxSecurityCrypto" }
|
||||||
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
|
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
|
||||||
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", 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" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user