feat: adb pair; mDNS discovering service
This commit is contained in:
11
README.md
11
README.md
@@ -12,9 +12,13 @@
|
||||
<img src="https://github.com/user-attachments/assets/6301f2fb-624b-4209-b548-6f37b9bcedc8" height="320" alt="Screenshot 3" />
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- 可替换 scrcpy-server
|
||||
- 利用 mDNS 服务实现自动连接启用无线调试的设备、自动发现等待配对设备的IP与端口
|
||||
|
||||
## 已知问题
|
||||
|
||||
- ADB 配对流程未实现
|
||||
- 多指触控抬起后滞留
|
||||
- 快速离开再进入全屏会导致视频流关键帧丢失
|
||||
|
||||
@@ -37,8 +41,9 @@ specific abi:
|
||||
## Credits
|
||||
|
||||
- [Genymobile/scrcpy](https://github.com/Genymobile/scrcpy)
|
||||
- [YuKongA/miuix](https://github.com/compose-miuix-ui/miuix)
|
||||
- [tiann/KernelSU/manager](https://github.com/tiann/KernelSU/tree/main/manager)
|
||||
- JNI ADB 实现: [rikkaapps/shizuku](https://github.com/rikkaapps/shizuku), [vvb2060/ndk.boringssl](https://github.com/vvb2060), [lsposed/libcxx](https://github.com/lsposed/libcxx)
|
||||
- 界面组件: [YuKongA/miuix](https://github.com/compose-miuix-ui/miuix)
|
||||
- 界面设计参考: [tiann/KernelSU/manager](https://github.com/tiann/KernelSU/tree/main/manager)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "ScrcpyForAndroid",
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"name": "scrcpy",
|
||||
"path": "../scrcpy"
|
||||
},
|
||||
{
|
||||
"name": "miuix",
|
||||
"path": "../miuix"
|
||||
},
|
||||
{
|
||||
"path": "../adblib"
|
||||
"name": "shizuku",
|
||||
"path": "../shizuku"
|
||||
},
|
||||
{
|
||||
"name": "KernelSU",
|
||||
"path": "../KernelSU"
|
||||
},
|
||||
{
|
||||
"name": "adblib",
|
||||
"path": "../adblib"
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -23,8 +23,14 @@ android {
|
||||
applicationId = "io.github.miuzarte.scrcpyforandroid"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 2
|
||||
versionName = "0.0.2"
|
||||
versionCode = 3
|
||||
versionName = "0.0.3"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += listOf("-DANDROID_STL=none")
|
||||
}
|
||||
}
|
||||
|
||||
ndk {
|
||||
abiFilters.clear()
|
||||
@@ -59,7 +65,15 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
prefab = true
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/jni/CMakeLists.txt")
|
||||
}
|
||||
}
|
||||
|
||||
buildToolsVersion = "36.0.0"
|
||||
ndkVersion = "28.2.13676358"
|
||||
}
|
||||
@@ -78,6 +92,10 @@ dependencies {
|
||||
implementation(libs.miuix)
|
||||
implementation(libs.miuix.icons)
|
||||
implementation(libs.miuix.navigation3.ui)
|
||||
implementation("io.github.vvb2060.ndk:boringssl:20250114")
|
||||
implementation("org.lsposed.libcxx:libcxx:27.0.12077973")
|
||||
implementation("org.bouncycastle:bcpkix-jdk18on:1.80")
|
||||
implementation("org.conscrypt:conscrypt-android:2.5.2")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -34,6 +34,7 @@ class NativeCoreFacade(private val appContext: Context) {
|
||||
private val bootstrapLock = Any()
|
||||
private val bootstrapPackets = ArrayDeque<CachedPacket>()
|
||||
private var packetCount: Long = 0
|
||||
|
||||
@Volatile
|
||||
private var audioPlayer: ScrcpyAudioPlayer? = null
|
||||
|
||||
@@ -91,6 +92,20 @@ class NativeCoreFacade(private val appContext: Context) {
|
||||
return ioCall { adbService.pair(host, port, pairingCode) }
|
||||
}
|
||||
|
||||
fun adbDiscoverPairingService(
|
||||
timeoutMs: Long = 12_000,
|
||||
includeLanDevices: Boolean = true,
|
||||
): Pair<String, Int>? {
|
||||
return ioCall { adbService.discoverPairingService(timeoutMs, includeLanDevices) }
|
||||
}
|
||||
|
||||
fun adbDiscoverConnectService(
|
||||
timeoutMs: Long = 12_000,
|
||||
includeLanDevices: Boolean = true,
|
||||
): Pair<String, Int>? {
|
||||
return ioCall { adbService.discoverConnectService(timeoutMs, includeLanDevices) }
|
||||
}
|
||||
|
||||
fun adbConnect(host: String, port: Int): Boolean = ioCall { adbService.connect(host, port) }
|
||||
|
||||
fun adbDisconnect(): Boolean {
|
||||
|
||||
@@ -76,4 +76,7 @@ object AppDefaults {
|
||||
|
||||
const val ADB_KEY_NAME = "scrcpy"
|
||||
const val ADB_KEY_NAME_INPUT = ""
|
||||
const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = true
|
||||
const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = true
|
||||
const val ADB_MDNS_LAN_DISCOVERY = true
|
||||
}
|
||||
|
||||
@@ -75,4 +75,7 @@ object AppPreferenceKeys {
|
||||
const val SERVER_REMOTE_PATH = "server_remote_path"
|
||||
|
||||
const val ADB_KEY_NAME = "adb_key_name"
|
||||
const val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = "adb_pairing_auto_discover_on_dialog_open"
|
||||
const val ADB_AUTO_RECONNECT_PAIRED_DEVICE = "adb_auto_reconnect_paired_device"
|
||||
const val ADB_MDNS_LAN_DISCOVERY = "adb_mdns_lan_discovery"
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.nativecore
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.ServerSocket
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
internal class AdbMdnsDiscoverer(context: Context) {
|
||||
|
||||
private val nsdManager = context.getSystemService(NsdManager::class.java)
|
||||
|
||||
fun discoverPairingService(timeoutMs: Long, includeLanDevices: Boolean): Pair<String, Int>? {
|
||||
return discoverService(TLS_PAIRING, timeoutMs, includeLanDevices)
|
||||
}
|
||||
|
||||
fun discoverConnectService(timeoutMs: Long, includeLanDevices: Boolean): Pair<String, Int>? {
|
||||
return discoverService(TLS_CONNECT, timeoutMs, includeLanDevices)
|
||||
}
|
||||
|
||||
private fun discoverService(
|
||||
serviceType: String,
|
||||
timeoutMs: Long,
|
||||
includeLanDevices: Boolean,
|
||||
): Pair<String, Int>? {
|
||||
val resultPort = AtomicInteger(-1)
|
||||
val resultHost = AtomicReference<String?>(null)
|
||||
val discoveryFinished = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
val discoveryListener = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(serviceType: String) {
|
||||
Log.v(TAG, "discovery started: $serviceType")
|
||||
}
|
||||
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.w(TAG, "start discovery failed: $serviceType, error=$errorCode")
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String) {
|
||||
Log.v(TAG, "discovery stopped: $serviceType")
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.w(TAG, "stop discovery failed: $serviceType, error=$errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (discoveryFinished.get()) return
|
||||
Log.v(TAG, "service found: ${serviceInfo.serviceName}")
|
||||
val resolveListener = object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.v(TAG, "resolve failed: ${serviceInfo.serviceName}, error=$errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
if (discoveryFinished.get()) return
|
||||
val hostAddress = serviceInfo.host?.hostAddress ?: return
|
||||
if (hostAddress.isBlank()) return
|
||||
|
||||
if (!includeLanDevices) {
|
||||
val isLocalHost = runCatching {
|
||||
NetworkInterface.getNetworkInterfaces().asSequence().any { intf ->
|
||||
intf.inetAddresses.asSequence().any { addr ->
|
||||
addr.hostAddress == hostAddress
|
||||
}
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
if (!isLocalHost) return
|
||||
if (!isPortOpened(serviceInfo.port)) return
|
||||
}
|
||||
|
||||
if (resultPort.compareAndSet(-1, serviceInfo.port)) {
|
||||
resultHost.set(hostAddress)
|
||||
discoveryFinished.set(true)
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
nsdManager.resolveService(serviceInfo, resolveListener)
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "resolveService failed for ${serviceInfo.serviceName}", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "service lost: ${serviceInfo.serviceName}")
|
||||
}
|
||||
}
|
||||
|
||||
nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||
latch.await(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
runCatching { nsdManager.stopServiceDiscovery(discoveryListener) }
|
||||
|
||||
val port = resultPort.get()
|
||||
val host = resultHost.get()
|
||||
return if (port > 0 && !host.isNullOrBlank()) host to port else null
|
||||
}
|
||||
|
||||
private fun isPortOpened(port: Int): Boolean = try {
|
||||
ServerSocket().use {
|
||||
it.bind(InetSocketAddress("127.0.0.1", port), 1)
|
||||
false
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AdbMdnsDiscoverer"
|
||||
private const val TLS_CONNECT = "_adb-tls-connect._tcp"
|
||||
private const val TLS_PAIRING = "_adb-tls-pairing._tcp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.nativecore
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import org.bouncycastle.asn1.x500.X500Name
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
|
||||
import org.conscrypt.Conscrypt
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.KeyFactory
|
||||
import java.security.PrivateKey
|
||||
import java.security.Provider
|
||||
import java.security.SecureRandom
|
||||
import java.security.Security
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.interfaces.RSAPrivateKey
|
||||
import java.security.interfaces.RSAPublicKey
|
||||
import java.security.spec.RSAPublicKeySpec
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509ExtendedTrustManager
|
||||
|
||||
internal class AdbPairingKey(
|
||||
private val privateKey: PrivateKey,
|
||||
private val alias: String,
|
||||
) {
|
||||
|
||||
private val rsaPrivateKey: RSAPrivateKey = privateKey as? RSAPrivateKey
|
||||
?: throw IllegalStateException("Expected RSA private key")
|
||||
|
||||
private val rsaPublicKey: RSAPublicKey by lazy {
|
||||
val keyFactory = KeyFactory.getInstance("RSA")
|
||||
keyFactory.generatePublic(
|
||||
RSAPublicKeySpec(rsaPrivateKey.modulus, BigInteger.valueOf(65537L)),
|
||||
) as RSAPublicKey
|
||||
}
|
||||
|
||||
private val certificate: X509Certificate by lazy {
|
||||
val signer = JcaContentSignerBuilder("SHA256withRSA").build(rsaPrivateKey)
|
||||
val builder = X509v3CertificateBuilder(
|
||||
X500Name("CN=00"),
|
||||
BigInteger.ONE,
|
||||
java.util.Date(0),
|
||||
java.util.Date(2_461_449_600_000L),
|
||||
X500Name("CN=00"),
|
||||
SubjectPublicKeyInfo.getInstance(rsaPublicKey.encoded),
|
||||
)
|
||||
val encoded = builder.build(signer).encoded
|
||||
CertificateFactory.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(encoded)) as X509Certificate
|
||||
}
|
||||
|
||||
val adbPublicKey: ByteArray by lazy { rsaPublicKey.adbEncoded(alias) }
|
||||
|
||||
val sslContext: SSLContext by lazy {
|
||||
val conscryptProvider: Provider = Conscrypt.newProviderBuilder().build()
|
||||
if (Security.getProvider(conscryptProvider.name) == null) {
|
||||
Security.insertProviderAt(conscryptProvider, 1)
|
||||
}
|
||||
val context = SSLContext.getInstance("TLSv1.3", conscryptProvider)
|
||||
context.init(arrayOf(keyManager), arrayOf(trustManager), SecureRandom())
|
||||
context
|
||||
}
|
||||
|
||||
private val keyManager: X509ExtendedKeyManager
|
||||
get() = object : X509ExtendedKeyManager() {
|
||||
private val keyAlias = "adbkey"
|
||||
|
||||
override fun chooseClientAlias(
|
||||
keyType: Array<out String>?,
|
||||
issuers: Array<out java.security.Principal>?,
|
||||
socket: Socket?,
|
||||
): String = keyAlias
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
|
||||
return if (alias == keyAlias) arrayOf(certificate) else null
|
||||
}
|
||||
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? {
|
||||
return if (alias == keyAlias) rsaPrivateKey else null
|
||||
}
|
||||
|
||||
override fun getClientAliases(
|
||||
keyType: String?,
|
||||
issuers: Array<out java.security.Principal>?,
|
||||
): Array<String>? = null
|
||||
|
||||
override fun getServerAliases(
|
||||
keyType: String?,
|
||||
issuers: Array<out java.security.Principal>?,
|
||||
): Array<String>? = null
|
||||
|
||||
override fun chooseServerAlias(
|
||||
keyType: String?,
|
||||
issuers: Array<out java.security.Principal>?,
|
||||
socket: Socket?,
|
||||
): String? = null
|
||||
}
|
||||
|
||||
@get:SuppressLint("CustomX509TrustManager")
|
||||
@get:Suppress("TrustAllX509TrustManager")
|
||||
private val trustManager: X509ExtendedTrustManager
|
||||
get() = object : X509ExtendedTrustManager() {
|
||||
// ADB pairing uses SPAKE2 + exported keying material to authenticate the peer.
|
||||
// The peer cert is ephemeral/self-signed, so PKIX validation is intentionally bypassed here.
|
||||
private fun acceptForPairing(
|
||||
chain: Array<out X509Certificate>?,
|
||||
authType: String?,
|
||||
) {
|
||||
if (chain.isNullOrEmpty()) return
|
||||
if (authType.isNullOrBlank()) return
|
||||
}
|
||||
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<out X509Certificate>?,
|
||||
authType: String?,
|
||||
socket: Socket?,
|
||||
) {
|
||||
acceptForPairing(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<out X509Certificate>?,
|
||||
authType: String?,
|
||||
engine: SSLEngine?,
|
||||
) {
|
||||
acceptForPairing(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||
acceptForPairing(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<out X509Certificate>?,
|
||||
authType: String?,
|
||||
socket: Socket?,
|
||||
) {
|
||||
acceptForPairing(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<out X509Certificate>?,
|
||||
authType: String?,
|
||||
engine: SSLEngine?,
|
||||
) {
|
||||
acceptForPairing(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||
acceptForPairing(chain, authType)
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
}
|
||||
}
|
||||
|
||||
private const val ANDROID_PUBKEY_MODULUS_SIZE = 2048 / 8
|
||||
private const val ANDROID_PUBKEY_MODULUS_SIZE_WORDS = ANDROID_PUBKEY_MODULUS_SIZE / 4
|
||||
private const val RSA_PUBLIC_KEY_SIZE = 524
|
||||
|
||||
private fun BigInteger.toAdbEncoded(): IntArray {
|
||||
val encoded = IntArray(ANDROID_PUBKEY_MODULUS_SIZE_WORDS)
|
||||
val r32 = BigInteger.ZERO.setBit(32)
|
||||
var tmp = this
|
||||
for (i in 0 until ANDROID_PUBKEY_MODULUS_SIZE_WORDS) {
|
||||
val out = tmp.divideAndRemainder(r32)
|
||||
tmp = out[0]
|
||||
encoded[i] = out[1].toInt()
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
private fun RSAPublicKey.adbEncoded(name: String): ByteArray {
|
||||
val r32 = BigInteger.ZERO.setBit(32)
|
||||
val n0inv = modulus.remainder(r32).modInverse(r32).negate()
|
||||
val r = BigInteger.ZERO.setBit(ANDROID_PUBKEY_MODULUS_SIZE * 8)
|
||||
val rr = r.modPow(BigInteger.valueOf(2), modulus)
|
||||
|
||||
val buffer = ByteBuffer.allocate(RSA_PUBLIC_KEY_SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
||||
buffer.putInt(ANDROID_PUBKEY_MODULUS_SIZE_WORDS)
|
||||
buffer.putInt(n0inv.toInt())
|
||||
modulus.toAdbEncoded().forEach { buffer.putInt(it) }
|
||||
rr.toAdbEncoded().forEach { buffer.putInt(it) }
|
||||
buffer.putInt(publicExponent.toInt())
|
||||
|
||||
val base64 = android.util.Base64.encode(buffer.array(), android.util.Base64.NO_WRAP)
|
||||
val suffix = " $name\u0000".toByteArray(Charsets.UTF_8)
|
||||
return ByteArray(base64.size + suffix.size).also {
|
||||
base64.copyInto(it)
|
||||
suffix.copyInto(it, base64.size)
|
||||
}
|
||||
}
|
||||
|
||||
internal class AdbInvalidPairingCodeException : Exception()
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.nativecore
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
@@ -32,6 +33,7 @@ import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.net.ssl.SSLSocket
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
internal class DirectAdbTransport(private val context: Context) {
|
||||
@@ -57,6 +59,41 @@ internal class DirectAdbTransport(private val context: Context) {
|
||||
return conn
|
||||
}
|
||||
|
||||
fun pair(host: String, port: Int, pairingCode: String): Boolean {
|
||||
val targetHost = host.trim()
|
||||
val targetCode = pairingCode.trim()
|
||||
require(targetHost.isNotBlank()) { "host is blank" }
|
||||
require(targetCode.isNotBlank()) { "pairing code is blank" }
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
throw UnsupportedOperationException("ADB pairing requires Android 11+")
|
||||
}
|
||||
|
||||
val pairingKey = AdbPairingKey(
|
||||
privateKey = privateKey,
|
||||
alias = keyName.ifBlank { AppDefaults.ADB_KEY_NAME },
|
||||
)
|
||||
return DirectAdbPairingClient(targetHost, port, targetCode, pairingKey).use {
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
fun discoverPairingService(
|
||||
timeoutMs: Long = 12_000,
|
||||
includeLanDevices: Boolean = true
|
||||
): Pair<String, Int>? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
|
||||
return AdbMdnsDiscoverer(context).discoverPairingService(timeoutMs, includeLanDevices)
|
||||
}
|
||||
|
||||
fun discoverConnectService(
|
||||
timeoutMs: Long = 12_000,
|
||||
includeLanDevices: Boolean = true
|
||||
): Pair<String, Int>? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
|
||||
return AdbMdnsDiscoverer(context).discoverConnectService(timeoutMs, includeLanDevices)
|
||||
}
|
||||
|
||||
private fun loadOrCreate(): Pair<PrivateKey, ByteArray> {
|
||||
val prefs = context.getSharedPreferences(
|
||||
AppPreferenceKeys.NATIVE_ADB_KEY_PREFS_NAME,
|
||||
@@ -104,6 +141,41 @@ internal class DirectAdbTransport(private val context: Context) {
|
||||
return digest.joinToString(":") { b -> "%02x".format(b) }
|
||||
}
|
||||
|
||||
private fun encodeAdbPublicKey(modulus: BigInteger, exponent: Int): ByteArray {
|
||||
val words = 64
|
||||
val bytes = 256
|
||||
val two32 = BigInteger.ONE.shiftLeft(32)
|
||||
val mask32 = two32.subtract(BigInteger.ONE)
|
||||
|
||||
fun toBigEndianPadded(n: BigInteger): ByteArray {
|
||||
val raw = n.toByteArray()
|
||||
val arr = ByteArray(bytes)
|
||||
val src = if (raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw
|
||||
src.copyInto(arr, destinationOffset = bytes - src.size)
|
||||
return arr
|
||||
}
|
||||
|
||||
val modBE = toBigEndianPadded(modulus)
|
||||
val n0 = modulus.and(mask32)
|
||||
val n0inv = n0.modInverse(two32).negate().mod(two32).toInt()
|
||||
val r = BigInteger.ONE.shiftLeft(bytes * 8)
|
||||
val rrBE = toBigEndianPadded(r.multiply(r).mod(modulus))
|
||||
|
||||
val buf = ByteBuffer.allocate(4 + 4 + bytes + bytes + 4).order(ByteOrder.LITTLE_ENDIAN)
|
||||
buf.putInt(words)
|
||||
buf.putInt(n0inv)
|
||||
for (i in words - 1 downTo 0) {
|
||||
val o = i * 4
|
||||
buf.put(modBE[o + 3]); buf.put(modBE[o + 2]); buf.put(modBE[o + 1]); buf.put(modBE[o])
|
||||
}
|
||||
for (i in words - 1 downTo 0) {
|
||||
val o = i * 4
|
||||
buf.put(rrBE[o + 3]); buf.put(rrBE[o + 2]); buf.put(rrBE[o + 1]); buf.put(rrBE[o])
|
||||
}
|
||||
buf.putInt(exponent)
|
||||
return buf.array()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DirectAdbTransport"
|
||||
}
|
||||
@@ -138,6 +210,7 @@ internal class DirectAdbConnection(
|
||||
private val socket = Socket()
|
||||
private lateinit var rawIn: BufferedInputStream
|
||||
private lateinit var rawOut: OutputStream
|
||||
private var tlsSocket: SSLSocket? = null
|
||||
private val nextLocalId = AtomicInteger(1)
|
||||
private val streams = ConcurrentHashMap<Int, AdbSocketStream>()
|
||||
|
||||
@@ -149,10 +222,12 @@ internal class DirectAdbConnection(
|
||||
private const val TAG = "DirectAdbConnection"
|
||||
private const val A_CNXN = 0x4e584e43
|
||||
private const val A_AUTH = 0x48545541
|
||||
private const val A_STLS = 0x534c5453
|
||||
private const val A_OPEN = 0x4e45504f
|
||||
private const val A_OKAY = 0x59414b4f
|
||||
private const val A_CLSE = 0x45534c43
|
||||
private const val A_WRTE = 0x45545257
|
||||
private const val STLS_VERSION = 0x01000000
|
||||
private const val AUTH_TOKEN = 1
|
||||
private const val AUTH_SIGNATURE = 2
|
||||
private const val AUTH_RSAPUBLICKEY = 3
|
||||
@@ -170,7 +245,13 @@ internal class DirectAdbConnection(
|
||||
|
||||
sendMsg(A_CNXN, VERSION, MAX_PAYLOAD, "host::\u0000".toByteArray(Charsets.UTF_8))
|
||||
|
||||
val first = recvMsg()
|
||||
var first = recvMsg()
|
||||
if (first.command == A_STLS) {
|
||||
sendMsg(A_STLS, STLS_VERSION, 0)
|
||||
upgradeToTls()
|
||||
first = recvMsg()
|
||||
}
|
||||
|
||||
when (first.command) {
|
||||
A_CNXN -> Unit
|
||||
A_AUTH -> {
|
||||
@@ -209,6 +290,19 @@ internal class DirectAdbConnection(
|
||||
readerThread = thread(isDaemon = true, name = "adb-reader-$host:$port") { readLoop() }
|
||||
}
|
||||
|
||||
private fun upgradeToTls() {
|
||||
val pairingKey = AdbPairingKey(
|
||||
privateKey = privateKey,
|
||||
alias = keyName,
|
||||
)
|
||||
val sslSocket = pairingKey.sslContext.socketFactory
|
||||
.createSocket(socket, host, port, true) as SSLSocket
|
||||
sslSocket.startHandshake()
|
||||
tlsSocket = sslSocket
|
||||
rawIn = BufferedInputStream(sslSocket.inputStream, 65_536)
|
||||
rawOut = sslSocket.outputStream
|
||||
}
|
||||
|
||||
fun openStream(service: String): AdbSocketStream {
|
||||
val localId = nextLocalId.getAndIncrement()
|
||||
val stream = AdbSocketStream(localId) { cmd, a0, a1, d -> sendMsg(cmd, a0, a1, d) }
|
||||
@@ -270,6 +364,7 @@ internal class DirectAdbConnection(
|
||||
closed = true
|
||||
streams.values.forEach { runCatching { it.forceClose() } }
|
||||
streams.clear()
|
||||
runCatching { tlsSocket?.close() }
|
||||
runCatching { socket.close() }
|
||||
runCatching { readerThread?.interrupt() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.nativecore
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.conscrypt.Conscrypt
|
||||
import java.io.Closeable
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import javax.net.ssl.SSLSocket
|
||||
|
||||
private const val TAG = "DirectAdbPairing"
|
||||
|
||||
private const val CURRENT_KEY_HEADER_VERSION = 1.toByte()
|
||||
private const val MIN_SUPPORTED_KEY_HEADER_VERSION = 1.toByte()
|
||||
private const val MAX_SUPPORTED_KEY_HEADER_VERSION = 1.toByte()
|
||||
private const val MAX_PEER_INFO_SIZE = 8192
|
||||
private const val MAX_PAYLOAD_SIZE = MAX_PEER_INFO_SIZE * 2
|
||||
|
||||
private const val EXPORTED_KEY_LABEL = "adb-label\u0000"
|
||||
private const val EXPORTED_KEY_SIZE = 64
|
||||
private const val PAIRING_PACKET_HEADER_SIZE = 6
|
||||
|
||||
private class PeerInfo(val type: Byte, rawData: ByteArray) {
|
||||
val data = ByteArray(MAX_PEER_INFO_SIZE - 1)
|
||||
|
||||
init {
|
||||
rawData.copyInto(data, endIndex = rawData.size.coerceAtMost(MAX_PEER_INFO_SIZE - 1))
|
||||
}
|
||||
|
||||
fun writeTo(buffer: ByteBuffer) {
|
||||
buffer.put(type)
|
||||
buffer.put(data)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ADB_RSA_PUB_KEY: Byte = 0
|
||||
|
||||
fun readFrom(buffer: ByteBuffer): PeerInfo {
|
||||
val type = buffer.get()
|
||||
val data = ByteArray(MAX_PEER_INFO_SIZE - 1)
|
||||
buffer.get(data)
|
||||
return PeerInfo(type, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PairingPacketHeader(val version: Byte, val type: Byte, val payload: Int) {
|
||||
object Type {
|
||||
const val SPAKE2_MSG: Byte = 0
|
||||
const val PEER_INFO: Byte = 1
|
||||
}
|
||||
|
||||
fun writeTo(buffer: ByteBuffer) {
|
||||
buffer.put(version)
|
||||
buffer.put(type)
|
||||
buffer.putInt(payload)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun readFrom(buffer: ByteBuffer): PairingPacketHeader? {
|
||||
val version = buffer.get()
|
||||
val type = buffer.get()
|
||||
val payload = buffer.int
|
||||
|
||||
if (version !in MIN_SUPPORTED_KEY_HEADER_VERSION..MAX_SUPPORTED_KEY_HEADER_VERSION) {
|
||||
Log.e(TAG, "header version mismatch: $version")
|
||||
return null
|
||||
}
|
||||
if (type != Type.SPAKE2_MSG && type != Type.PEER_INFO) {
|
||||
Log.e(TAG, "unknown packet type: $type")
|
||||
return null
|
||||
}
|
||||
if (payload !in 1..MAX_PAYLOAD_SIZE) {
|
||||
Log.e(TAG, "unsafe payload size: $payload")
|
||||
return null
|
||||
}
|
||||
return PairingPacketHeader(version, type, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PairingContext private constructor(private val nativePtr: Long) {
|
||||
|
||||
val msg: ByteArray = nativeMsg(nativePtr)
|
||||
|
||||
fun initCipher(theirMsg: ByteArray): Boolean = nativeInitCipher(nativePtr, theirMsg)
|
||||
|
||||
fun encrypt(input: ByteArray): ByteArray? = nativeEncrypt(nativePtr, input)
|
||||
|
||||
fun decrypt(input: ByteArray): ByteArray? = nativeDecrypt(nativePtr, input)
|
||||
|
||||
fun destroy() {
|
||||
nativeDestroy(nativePtr)
|
||||
}
|
||||
|
||||
private external fun nativeMsg(nativePtr: Long): ByteArray
|
||||
private external fun nativeInitCipher(nativePtr: Long, theirMsg: ByteArray): Boolean
|
||||
private external fun nativeEncrypt(nativePtr: Long, inbuf: ByteArray): ByteArray?
|
||||
private external fun nativeDecrypt(nativePtr: Long, inbuf: ByteArray): ByteArray?
|
||||
private external fun nativeDestroy(nativePtr: Long)
|
||||
|
||||
companion object {
|
||||
fun create(password: ByteArray): PairingContext? {
|
||||
val ptr = nativeConstructor(true, password)
|
||||
return if (ptr != 0L) PairingContext(ptr) else null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeConstructor(isClient: Boolean, password: ByteArray): Long
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
internal class DirectAdbPairingClient(
|
||||
private val host: String,
|
||||
private val port: Int,
|
||||
private val pairingCode: String,
|
||||
private val key: AdbPairingKey,
|
||||
) : Closeable {
|
||||
|
||||
private enum class State {
|
||||
READY,
|
||||
EXCHANGING_MSGS,
|
||||
EXCHANGING_PEER_INFO,
|
||||
STOPPED,
|
||||
}
|
||||
|
||||
private lateinit var socket: Socket
|
||||
private lateinit var inputStream: DataInputStream
|
||||
private lateinit var outputStream: DataOutputStream
|
||||
|
||||
private val peerInfo = PeerInfo(PeerInfo.ADB_RSA_PUB_KEY, key.adbPublicKey)
|
||||
private lateinit var pairingContext: PairingContext
|
||||
private var state: State = State.READY
|
||||
|
||||
fun start(): Boolean {
|
||||
check(isLibraryLoaded) { "Pairing native bridge is unavailable" }
|
||||
|
||||
setupTlsConnection()
|
||||
state = State.EXCHANGING_MSGS
|
||||
if (!doExchangeMsgs()) {
|
||||
state = State.STOPPED
|
||||
return false
|
||||
}
|
||||
|
||||
state = State.EXCHANGING_PEER_INFO
|
||||
if (!doExchangePeerInfo()) {
|
||||
state = State.STOPPED
|
||||
return false
|
||||
}
|
||||
|
||||
state = State.STOPPED
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setupTlsConnection() {
|
||||
socket = Socket(host, port)
|
||||
socket.tcpNoDelay = true
|
||||
|
||||
val sslSocket = key.sslContext.socketFactory
|
||||
.createSocket(socket, host, port, true) as SSLSocket
|
||||
sslSocket.startHandshake()
|
||||
|
||||
inputStream = DataInputStream(sslSocket.inputStream)
|
||||
outputStream = DataOutputStream(sslSocket.outputStream)
|
||||
|
||||
val codeBytes = pairingCode.toByteArray(Charsets.UTF_8)
|
||||
val keyMaterial = exportTlsKeyingMaterial(sslSocket)
|
||||
val password = ByteArray(codeBytes.size + keyMaterial.size)
|
||||
codeBytes.copyInto(password)
|
||||
keyMaterial.copyInto(password, destinationOffset = codeBytes.size)
|
||||
|
||||
pairingContext = checkNotNull(PairingContext.create(password)) {
|
||||
"Unable to create pairing context"
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportTlsKeyingMaterial(sslSocket: SSLSocket): ByteArray {
|
||||
if (Conscrypt.isConscrypt(sslSocket)) {
|
||||
return Conscrypt.exportKeyingMaterial(
|
||||
sslSocket,
|
||||
EXPORTED_KEY_LABEL,
|
||||
null,
|
||||
EXPORTED_KEY_SIZE,
|
||||
)
|
||||
}
|
||||
|
||||
throw IllegalStateException(
|
||||
"TLS socket is not backed by bundled Conscrypt: ${sslSocket.javaClass.name}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun createHeader(type: Byte, payloadSize: Int): PairingPacketHeader {
|
||||
return PairingPacketHeader(CURRENT_KEY_HEADER_VERSION, type, payloadSize)
|
||||
}
|
||||
|
||||
private fun readHeader(): PairingPacketHeader? {
|
||||
val bytes = ByteArray(PAIRING_PACKET_HEADER_SIZE)
|
||||
inputStream.readFully(bytes)
|
||||
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
|
||||
return PairingPacketHeader.readFrom(buffer)
|
||||
}
|
||||
|
||||
private fun writeHeader(header: PairingPacketHeader, payload: ByteArray) {
|
||||
val buffer = ByteBuffer.allocate(PAIRING_PACKET_HEADER_SIZE).order(ByteOrder.BIG_ENDIAN)
|
||||
header.writeTo(buffer)
|
||||
outputStream.write(buffer.array())
|
||||
outputStream.write(payload)
|
||||
}
|
||||
|
||||
private fun doExchangeMsgs(): Boolean {
|
||||
val msg = pairingContext.msg
|
||||
writeHeader(createHeader(PairingPacketHeader.Type.SPAKE2_MSG, msg.size), msg)
|
||||
|
||||
val theirHeader = readHeader() ?: return false
|
||||
if (theirHeader.type != PairingPacketHeader.Type.SPAKE2_MSG) return false
|
||||
|
||||
val theirMessage = ByteArray(theirHeader.payload)
|
||||
inputStream.readFully(theirMessage)
|
||||
return pairingContext.initCipher(theirMessage)
|
||||
}
|
||||
|
||||
private fun doExchangePeerInfo(): Boolean {
|
||||
val plain = ByteBuffer.allocate(MAX_PEER_INFO_SIZE).order(ByteOrder.BIG_ENDIAN)
|
||||
peerInfo.writeTo(plain)
|
||||
|
||||
val encrypted = pairingContext.encrypt(plain.array()) ?: return false
|
||||
writeHeader(createHeader(PairingPacketHeader.Type.PEER_INFO, encrypted.size), encrypted)
|
||||
|
||||
val theirHeader = readHeader() ?: return false
|
||||
if (theirHeader.type != PairingPacketHeader.Type.PEER_INFO) return false
|
||||
|
||||
val peerMessage = ByteArray(theirHeader.payload)
|
||||
inputStream.readFully(peerMessage)
|
||||
|
||||
val decrypted =
|
||||
pairingContext.decrypt(peerMessage) ?: throw AdbInvalidPairingCodeException()
|
||||
if (decrypted.size != MAX_PEER_INFO_SIZE) {
|
||||
Log.e(TAG, "invalid peer info size: ${decrypted.size}")
|
||||
return false
|
||||
}
|
||||
PeerInfo.readFrom(ByteBuffer.wrap(decrypted).order(ByteOrder.BIG_ENDIAN))
|
||||
return true
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { inputStream.close() }
|
||||
runCatching { outputStream.close() }
|
||||
runCatching { socket.close() }
|
||||
if (state != State.READY) {
|
||||
runCatching { pairingContext.destroy() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val isLibraryLoaded: Boolean = runCatching {
|
||||
System.loadLibrary("adbpairing")
|
||||
true
|
||||
}.onFailure {
|
||||
Log.e(TAG, "loadLibrary(adbpairing) failed", it)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,13 @@ import java.nio.file.Path
|
||||
class NativeAdbService(appContext: Context) {
|
||||
|
||||
private val transport = DirectAdbTransport(appContext)
|
||||
|
||||
@Volatile
|
||||
private var connection: DirectAdbConnection? = null
|
||||
|
||||
@Volatile
|
||||
private var connectedHost: String? = null
|
||||
|
||||
@Volatile
|
||||
private var connectedPort: Int? = null
|
||||
|
||||
@@ -22,9 +25,44 @@ class NativeAdbService(appContext: Context) {
|
||||
|
||||
@Synchronized
|
||||
fun pair(host: String, port: Int, pairingCode: String): Boolean {
|
||||
throw UnsupportedOperationException(
|
||||
"Wireless pairing is not yet implemented. Please enable TCP ADB via USB first.",
|
||||
)
|
||||
val h = host.trim()
|
||||
val code = pairingCode.trim()
|
||||
require(h.isNotBlank()) { "host is blank" }
|
||||
require(code.isNotBlank()) { "pairing code is blank" }
|
||||
Log.i(TAG, "pair(): host=$h port=$port")
|
||||
return try {
|
||||
transport.pair(h, port, code)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "pair(): failed host=$h port=$port", e)
|
||||
val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
|
||||
throw IllegalStateException("ADB pair failed for $h:$port -> $detail", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun discoverPairingService(
|
||||
timeoutMs: Long = 12_000,
|
||||
includeLanDevices: Boolean = true
|
||||
): Pair<String, Int>? {
|
||||
return try {
|
||||
transport.discoverPairingService(timeoutMs, includeLanDevices)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "discoverPairingService(): failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun discoverConnectService(
|
||||
timeoutMs: Long = 12_000,
|
||||
includeLanDevices: Boolean = true
|
||||
): Pair<String, Int>? {
|
||||
return try {
|
||||
transport.discoverConnectService(timeoutMs, includeLanDevices)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "discoverConnectService(): failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
||||
@@ -24,6 +24,7 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
|
||||
|
||||
@Volatile
|
||||
private var prepared = false
|
||||
|
||||
@Volatile
|
||||
private var released = false
|
||||
private var packetCount = 0L
|
||||
|
||||
@@ -38,6 +38,7 @@ import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
|
||||
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.services.loadQuickDevices
|
||||
import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget
|
||||
import io.github.miuzarte.scrcpyforandroid.services.replaceQuickDevicePort
|
||||
import io.github.miuzarte.scrcpyforandroid.services.saveDevicePageSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.services.saveQuickDevices
|
||||
import io.github.miuzarte.scrcpyforandroid.services.updateQuickDeviceNameIfEmpty
|
||||
@@ -67,14 +68,19 @@ import kotlinx.coroutines.withTimeout
|
||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||
import top.yukonga.miuix.kmp.extra.SuperBottomSheet
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val ADB_CONNECT_TIMEOUT_MS = 3_000L
|
||||
private const val ADB_KEEPALIVE_INTERVAL_MS = 15_000L
|
||||
private const val ADB_KEEPALIVE_TIMEOUT_MS = 2_000L
|
||||
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L
|
||||
private const val ADB_KEEPALIVE_TIMEOUT_MS = 1_500L
|
||||
private const val ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS = 1_200L
|
||||
private const val ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS = 1_500L
|
||||
private const val ADB_TCP_PROBE_TIMEOUT_MS = 600
|
||||
private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F"
|
||||
private const val LOG_TAG = "DevicePage"
|
||||
|
||||
@@ -207,6 +213,9 @@ fun DeviceTabScreen(
|
||||
onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit,
|
||||
onOpenAdvancedPage: () -> Unit,
|
||||
onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit,
|
||||
adbPairingAutoDiscoverOnDialogOpen: Boolean,
|
||||
adbAutoReconnectPairedDevice: Boolean,
|
||||
adbMdnsLanDiscoveryEnabled: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptics = rememberAppHaptics()
|
||||
@@ -273,6 +282,7 @@ fun DeviceTabScreen(
|
||||
val eventLog = rememberSaveable(saver = StringStateListSaver) { mutableStateListOf() }
|
||||
val quickDevices =
|
||||
rememberSaveable(saver = DeviceShortcutStateListSaver) { mutableStateListOf() }
|
||||
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
|
||||
|
||||
LaunchedEffect(eventLog.size) {
|
||||
onCanClearLogsChange(eventLog.isNotEmpty())
|
||||
@@ -349,6 +359,17 @@ fun DeviceTabScreen(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun probeTcpReachable(host: String, port: Int): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Socket().use { socket ->
|
||||
socket.connect(InetSocketAddress(host, port), ADB_TCP_PROBE_TIMEOUT_MS)
|
||||
true
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) {
|
||||
if (busy) return
|
||||
scope.launch {
|
||||
@@ -606,6 +627,7 @@ fun DeviceTabScreen(
|
||||
snack.showSnackbar("ADB 自动重连成功")
|
||||
}
|
||||
} else {
|
||||
runCatching { nativeCore.adbDisconnect() }
|
||||
statusLine = "ADB 连接断开"
|
||||
connectedDeviceLabel = "未连接"
|
||||
sessionInfo = null
|
||||
@@ -618,6 +640,115 @@ fun DeviceTabScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscoveryEnabled) {
|
||||
if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect
|
||||
|
||||
val quickConnectTriedOnce = mutableSetOf<String>()
|
||||
while (!adbConnected && adbAutoReconnectPairedDevice) {
|
||||
if (busy || adbConnecting || sessionInfo != null) {
|
||||
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
val quickCandidates = quickDevices.toList()
|
||||
if (quickCandidates.isNotEmpty()) {
|
||||
for (target in quickCandidates) {
|
||||
if (adbConnected || adbConnecting) break
|
||||
if (sessionReconnectBlacklistHosts.contains(target.host)) continue
|
||||
val targetKey = "${target.host}:${target.port}"
|
||||
if (quickConnectTriedOnce.contains(targetKey)) continue
|
||||
|
||||
val portReachable = probeTcpReachable(target.host, target.port)
|
||||
if (!portReachable) continue
|
||||
|
||||
quickConnectTriedOnce += targetKey
|
||||
runAdbConnect("快速设备端口可达,尝试连接一次") {
|
||||
val ok = connectWithTimeout(target.host, target.port)
|
||||
adbConnected = ok
|
||||
upsertQuickDevice(
|
||||
context,
|
||||
quickDevices,
|
||||
target.host,
|
||||
target.port,
|
||||
ok
|
||||
)
|
||||
if (ok) {
|
||||
handleAdbConnected(target.host, target.port)
|
||||
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (adbConnected) break
|
||||
}
|
||||
|
||||
val discovered = withContext(Dispatchers.IO) {
|
||||
nativeCore.adbDiscoverConnectService(
|
||||
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
|
||||
includeLanDevices = adbMdnsLanDiscoveryEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
if (discovered == null) {
|
||||
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
val (discoveredHost, discoveredPort) = discovered
|
||||
if (sessionReconnectBlacklistHosts.contains(discoveredHost)) {
|
||||
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
val knownDevice = quickDevices.firstOrNull { it.host == discoveredHost }
|
||||
if (knownDevice == null) {
|
||||
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
val portToReplace = quickDevices.firstOrNull {
|
||||
it.host == discoveredHost &&
|
||||
it.port != AppDefaults.ADB_PORT &&
|
||||
it.port != discoveredPort
|
||||
}?.port
|
||||
if (portToReplace != null) {
|
||||
replaceQuickDevicePort(
|
||||
context = context,
|
||||
quickDevices = quickDevices,
|
||||
host = discoveredHost,
|
||||
oldPort = portToReplace,
|
||||
newPort = discoveredPort,
|
||||
online = false,
|
||||
)
|
||||
logEvent(
|
||||
"mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort"
|
||||
)
|
||||
}
|
||||
|
||||
if (adbConnected || adbConnecting) {
|
||||
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
runAdbConnect("自动重连 ADB") {
|
||||
val ok = connectWithTimeout(discoveredHost, discoveredPort)
|
||||
adbConnected = ok
|
||||
upsertQuickDevice(
|
||||
context,
|
||||
quickDevices,
|
||||
discoveredHost,
|
||||
discoveredPort,
|
||||
ok
|
||||
)
|
||||
if (ok) {
|
||||
handleAdbConnected(discoveredHost, discoveredPort)
|
||||
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
|
||||
} else {
|
||||
logEvent("ADB 自动重连失败: $discoveredHost:$discoveredPort", Log.WARN)
|
||||
}
|
||||
}
|
||||
|
||||
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(nativeCore) {
|
||||
val listener: (Int, Int) -> Unit = { width, height ->
|
||||
sessionInfo = sessionInfo?.copy(width = width, height = height)
|
||||
@@ -786,6 +917,7 @@ fun DeviceTabScreen(
|
||||
activeDeviceActionId = device.id
|
||||
runBusy("断开 ADB", onFinished = { activeDeviceActionId = null }) {
|
||||
nativeCore.adbDisconnect()
|
||||
sessionReconnectBlacklistHosts += host
|
||||
adbConnected = false
|
||||
currentTargetHost = ""
|
||||
currentTargetPort = AppDefaults.ADB_PORT
|
||||
@@ -862,12 +994,21 @@ fun DeviceTabScreen(
|
||||
// "使用配对码配对设备"
|
||||
PairingCard(
|
||||
busy = busy,
|
||||
autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
|
||||
onDiscoverTarget = {
|
||||
nativeCore.adbDiscoverPairingService(
|
||||
includeLanDevices = adbMdnsLanDiscoveryEnabled,
|
||||
)
|
||||
},
|
||||
onPair = { host, port, code ->
|
||||
runBusy("执行配对") {
|
||||
val resolvedHost = host.trim()
|
||||
val resolvedPort = port.toIntOrNull() ?: return@runBusy
|
||||
val resolvedCode = code.trim()
|
||||
val ok = nativeCore.adbPair(
|
||||
host.trim(),
|
||||
port.toIntOrNull() ?: AppDefaults.ADB_PORT,
|
||||
code.trim(),
|
||||
resolvedHost,
|
||||
resolvedPort,
|
||||
resolvedCode,
|
||||
)
|
||||
logEvent(
|
||||
if (ok) "配对成功" else "配对失败",
|
||||
@@ -1075,11 +1216,11 @@ fun DeviceTabScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(UiSpacing.PageItem))
|
||||
LogsPanel(lines = eventLog)
|
||||
}
|
||||
if (eventLog.isNotEmpty()) item {
|
||||
Spacer(Modifier.height(UiSpacing.PageItem))
|
||||
LogsPanel(lines = eventLog)
|
||||
}
|
||||
|
||||
// TODO: 放进 [AppPageLazyColumn] 里
|
||||
|
||||
@@ -131,6 +131,15 @@ fun MainPage() {
|
||||
var customServerUri by rememberSaveable { mutableStateOf(initialSettings.customServerUri) }
|
||||
var serverRemotePath by rememberSaveable { mutableStateOf(initialSettings.serverRemotePath) }
|
||||
var adbKeyName by rememberSaveable { mutableStateOf(initialSettings.adbKeyName) }
|
||||
var adbPairingAutoDiscoverOnDialogOpen by rememberSaveable {
|
||||
mutableStateOf(initialSettings.adbPairingAutoDiscoverOnDialogOpen)
|
||||
}
|
||||
var adbAutoReconnectPairedDevice by rememberSaveable {
|
||||
mutableStateOf(initialSettings.adbAutoReconnectPairedDevice)
|
||||
}
|
||||
var adbMdnsLanDiscoveryEnabled by rememberSaveable {
|
||||
mutableStateOf(initialSettings.adbMdnsLanDiscoveryEnabled)
|
||||
}
|
||||
var noControl by rememberSaveable { mutableStateOf(initialDeviceSettings.noControl) }
|
||||
var videoEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.videoEncoder) }
|
||||
var videoCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.videoCodecOptions) }
|
||||
@@ -191,6 +200,9 @@ fun MainPage() {
|
||||
customServerUri,
|
||||
serverRemotePath,
|
||||
adbKeyName,
|
||||
adbPairingAutoDiscoverOnDialogOpen,
|
||||
adbAutoReconnectPairedDevice,
|
||||
adbMdnsLanDiscoveryEnabled,
|
||||
) {
|
||||
saveMainSettings(
|
||||
context,
|
||||
@@ -209,6 +221,9 @@ fun MainPage() {
|
||||
customServerUri = customServerUri,
|
||||
serverRemotePath = serverRemotePath,
|
||||
adbKeyName = adbKeyName,
|
||||
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
|
||||
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
|
||||
adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -464,6 +479,9 @@ fun MainPage() {
|
||||
)
|
||||
},
|
||||
previewCardHeightDp = devicePreviewCardHeightDp,
|
||||
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
|
||||
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
|
||||
adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -508,6 +526,14 @@ fun MainPage() {
|
||||
onServerRemotePathChange = { serverRemotePath = it },
|
||||
adbKeyName = adbKeyName,
|
||||
onAdbKeyNameChange = { adbKeyName = it },
|
||||
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
|
||||
onAdbPairingAutoDiscoverOnDialogOpenChange = {
|
||||
adbPairingAutoDiscoverOnDialogOpen = it
|
||||
},
|
||||
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
|
||||
onAdbAutoReconnectPairedDeviceChange = {
|
||||
adbAutoReconnectPairedDevice = it
|
||||
},
|
||||
scrollBehavior = settingsScrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
@@ -71,6 +72,10 @@ fun SettingsScreen(
|
||||
onServerRemotePathChange: (String) -> Unit,
|
||||
adbKeyName: String,
|
||||
onAdbKeyNameChange: (String) -> Unit,
|
||||
adbPairingAutoDiscoverOnDialogOpen: Boolean,
|
||||
onAdbPairingAutoDiscoverOnDialogOpenChange: (Boolean) -> Unit,
|
||||
adbAutoReconnectPairedDevice: Boolean,
|
||||
onAdbAutoReconnectPairedDeviceChange: (Boolean) -> Unit,
|
||||
scrollBehavior: ScrollBehavior,
|
||||
) {
|
||||
val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
|
||||
@@ -206,7 +211,22 @@ fun SettingsScreen(
|
||||
.padding(horizontal = UiSpacing.CardContent)
|
||||
.padding(bottom = UiSpacing.CardContent),
|
||||
)
|
||||
SuperSwitch(
|
||||
title = "配对时自动启用发现服务",
|
||||
summary = "打开配对弹窗后自动搜索可用配对端口",
|
||||
checked = adbPairingAutoDiscoverOnDialogOpen,
|
||||
onCheckedChange = onAdbPairingAutoDiscoverOnDialogOpenChange,
|
||||
)
|
||||
SuperSwitch(
|
||||
title = "自动重连已配对设备",
|
||||
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
|
||||
checked = adbAutoReconnectPairedDevice,
|
||||
onCheckedChange = onAdbAutoReconnectPairedDeviceChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 放进 [AppPageLazyColumn] 里
|
||||
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ internal data class MainSettings(
|
||||
val customServerUri: String? = AppDefaults.CUSTOM_SERVER_URI,
|
||||
val serverRemotePath: String = AppDefaults.SERVER_REMOTE_PATH_INPUT,
|
||||
val adbKeyName: String = AppDefaults.ADB_KEY_NAME_INPUT,
|
||||
val adbPairingAutoDiscoverOnDialogOpen: Boolean =
|
||||
AppDefaults.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
|
||||
val adbAutoReconnectPairedDevice: Boolean = AppDefaults.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
|
||||
val adbMdnsLanDiscoveryEnabled: Boolean = AppDefaults.ADB_MDNS_LAN_DISCOVERY,
|
||||
)
|
||||
|
||||
internal data class DevicePageSettings(
|
||||
@@ -125,6 +129,18 @@ internal fun loadMainSettings(context: Context): MainSettings {
|
||||
AppPreferenceKeys.ADB_KEY_NAME,
|
||||
AppDefaults.ADB_KEY_NAME_INPUT,
|
||||
).orEmpty(),
|
||||
adbPairingAutoDiscoverOnDialogOpen = prefs.getBoolean(
|
||||
AppPreferenceKeys.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
|
||||
AppDefaults.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
|
||||
),
|
||||
adbAutoReconnectPairedDevice = prefs.getBoolean(
|
||||
AppPreferenceKeys.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
|
||||
AppDefaults.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
|
||||
),
|
||||
adbMdnsLanDiscoveryEnabled = prefs.getBoolean(
|
||||
AppPreferenceKeys.ADB_MDNS_LAN_DISCOVERY,
|
||||
AppDefaults.ADB_MDNS_LAN_DISCOVERY,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,6 +205,18 @@ internal fun saveMainSettings(context: Context, settings: MainSettings) {
|
||||
AppPreferenceKeys.ADB_KEY_NAME,
|
||||
settings.adbKeyName,
|
||||
)
|
||||
.putBoolean(
|
||||
AppPreferenceKeys.ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN,
|
||||
settings.adbPairingAutoDiscoverOnDialogOpen,
|
||||
)
|
||||
.putBoolean(
|
||||
AppPreferenceKeys.ADB_AUTO_RECONNECT_PAIRED_DEVICE,
|
||||
settings.adbAutoReconnectPairedDevice,
|
||||
)
|
||||
.putBoolean(
|
||||
AppPreferenceKeys.ADB_MDNS_LAN_DISCOVERY,
|
||||
settings.adbMdnsLanDiscoveryEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,3 +110,28 @@ internal fun updateQuickDeviceNameIfEmpty(
|
||||
saveQuickDevices(context, quickDevices)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun replaceQuickDevicePort(
|
||||
context: Context,
|
||||
quickDevices: MutableList<DeviceShortcut>,
|
||||
host: String,
|
||||
oldPort: Int,
|
||||
newPort: Int,
|
||||
online: Boolean,
|
||||
) {
|
||||
val idx = quickDevices.indexOfFirst { it.host == host && it.port == oldPort }
|
||||
if (idx < 0) return
|
||||
|
||||
val old = quickDevices[idx]
|
||||
val updated = old.copy(
|
||||
id = "$host:$newPort",
|
||||
port = newPort,
|
||||
online = online,
|
||||
)
|
||||
|
||||
quickDevices[idx] = updated
|
||||
val dedup = quickDevices.distinctBy { it.id }
|
||||
quickDevices.clear()
|
||||
quickDevices.addAll(dedup)
|
||||
saveQuickDevices(context, quickDevices)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -70,6 +71,9 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yukonga.miuix.kmp.basic.Button
|
||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
@@ -205,6 +209,8 @@ internal fun StatusCard(
|
||||
@Composable
|
||||
internal fun PairingCard(
|
||||
busy: Boolean,
|
||||
autoDiscoverOnDialogOpen: Boolean,
|
||||
onDiscoverTarget: (() -> Pair<String, Int>?)? = null,
|
||||
onPair: (host: String, port: String, code: String) -> Unit,
|
||||
) {
|
||||
val showPairDialog = remember { mutableStateOf(false) }
|
||||
@@ -225,6 +231,8 @@ internal fun PairingCard(
|
||||
PairingDialog(
|
||||
showDialog = showPairDialog.value,
|
||||
enabled = !busy,
|
||||
autoDiscoverOnDialogOpen = autoDiscoverOnDialogOpen,
|
||||
onDiscoverTarget = onDiscoverTarget,
|
||||
onDismissRequest = { showPairDialog.value = false },
|
||||
onDismissFinished = { holdDownState.value = false },
|
||||
onConfirm = { host, port, code ->
|
||||
@@ -480,6 +488,8 @@ internal fun ConfigPanel(
|
||||
private fun PairingDialog(
|
||||
showDialog: Boolean,
|
||||
enabled: Boolean,
|
||||
autoDiscoverOnDialogOpen: Boolean,
|
||||
onDiscoverTarget: (() -> Pair<String, Int>?)?,
|
||||
onDismissRequest: () -> Unit,
|
||||
onDismissFinished: () -> Unit,
|
||||
onConfirm: (host: String, port: String, code: String) -> Unit,
|
||||
@@ -487,11 +497,31 @@ private fun PairingDialog(
|
||||
var host by rememberSaveable(showDialog) { mutableStateOf("") }
|
||||
var port by rememberSaveable(showDialog) { mutableStateOf("") }
|
||||
var code by rememberSaveable(showDialog) { mutableStateOf("") }
|
||||
var discoveringPort by rememberSaveable(showDialog) { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
suspend fun doDiscover() {
|
||||
if (onDiscoverTarget == null || discoveringPort || !enabled) return
|
||||
discoveringPort = true
|
||||
val found = withContext(Dispatchers.IO) { onDiscoverTarget.invoke() }
|
||||
if (found != null) {
|
||||
host = found.first
|
||||
port = found.second.toString()
|
||||
}
|
||||
discoveringPort = false
|
||||
}
|
||||
|
||||
LaunchedEffect(showDialog, autoDiscoverOnDialogOpen, onDiscoverTarget, enabled) {
|
||||
if (showDialog && autoDiscoverOnDialogOpen && onDiscoverTarget != null && !discoveringPort) {
|
||||
doDiscover()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearInputs() {
|
||||
host = ""
|
||||
port = ""
|
||||
code = ""
|
||||
discoveringPort = false
|
||||
}
|
||||
|
||||
SuperDialog(
|
||||
@@ -530,9 +560,30 @@ private fun PairingDialog(
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = UiSpacing.Large),
|
||||
.padding(bottom = UiSpacing.CardContent),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.PopupHorizontal)) {
|
||||
|
||||
TextButton(
|
||||
text = if (discoveringPort) "发现中..." else "自动发现",
|
||||
onClick = {
|
||||
if (onDiscoverTarget == null || discoveringPort || !enabled) return@TextButton
|
||||
scope.launch {
|
||||
doDiscover()
|
||||
}
|
||||
},
|
||||
enabled = enabled && onDiscoverTarget != null && !discoveringPort,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
top = UiSpacing.Medium,
|
||||
bottom = UiSpacing.CardContent,
|
||||
),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(bottom = UiSpacing.PopupHorizontal),
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.PopupHorizontal),
|
||||
) {
|
||||
TextButton(
|
||||
text = "取消",
|
||||
onClick = {
|
||||
@@ -544,10 +595,13 @@ private fun PairingDialog(
|
||||
TextButton(
|
||||
text = "配对",
|
||||
onClick = {
|
||||
onConfirm(host.trim(), port.trim().ifBlank { "37099" }, code.trim())
|
||||
onConfirm(host.trim(), port.trim(), code.trim())
|
||||
clearInputs()
|
||||
},
|
||||
enabled = enabled && host.isNotBlank() && code.isNotBlank(),
|
||||
enabled = enabled &&
|
||||
host.trim().isNotBlank() &&
|
||||
port.trim().isNotBlank() &&
|
||||
code.trim().isNotBlank(),
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||
)
|
||||
@@ -570,9 +624,7 @@ private fun formatBitRate(value: Float): String = String.format("%.1f", value)
|
||||
|
||||
@Composable
|
||||
internal fun LogsPanel(lines: List<String>) {
|
||||
Card(
|
||||
pressFeedbackType = PressFeedbackType.Sink,
|
||||
) {
|
||||
Card {
|
||||
TextField(
|
||||
value = lines.joinToString(separator = "\n"),
|
||||
onValueChange = {},
|
||||
|
||||
@@ -129,9 +129,11 @@ internal fun StatusCardLayout(
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
StatusMetricCard(
|
||||
spec = spec.firstSmall,
|
||||
modifier = Modifier
|
||||
|
||||
17
app/src/main/jni/CMakeLists.txt
Normal file
17
app/src/main/jni/CMakeLists.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(scrcpyforandroid)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
add_compile_options(-fno-exceptions -fno-rtti -fno-threadsafe-statics)
|
||||
|
||||
find_package(boringssl REQUIRED CONFIG)
|
||||
find_package(cxx REQUIRED CONFIG)
|
||||
|
||||
add_library(adbpairing SHARED adb_pairing.cpp)
|
||||
|
||||
target_link_libraries(
|
||||
adbpairing
|
||||
log
|
||||
boringssl::crypto_static
|
||||
cxx::cxx
|
||||
)
|
||||
302
app/src/main/jni/adb_pairing.cpp
Normal file
302
app/src/main/jni/adb_pairing.cpp
Normal file
@@ -0,0 +1,302 @@
|
||||
#include <jni.h>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include <cinttypes>
|
||||
#include <openssl/curve25519.h>
|
||||
#include <openssl/hkdf.h>
|
||||
#include <openssl/evp.h>
|
||||
#include "adb_pairing.h"
|
||||
|
||||
#define LOG_TAG "AdbPairClient"
|
||||
#include "logging.h"
|
||||
|
||||
static constexpr spake2_role_t kClientRole = spake2_role_alice;
|
||||
static constexpr spake2_role_t kServerRole = spake2_role_bob;
|
||||
|
||||
static const uint8_t kClientName[] = "adb pair client";
|
||||
static const uint8_t kServerName[] = "adb pair server";
|
||||
|
||||
static constexpr size_t kHkdfKeyLength = 16;
|
||||
|
||||
struct PairingContextNative {
|
||||
SPAKE2_CTX* spake2_ctx;
|
||||
uint8_t key[SPAKE2_MAX_MSG_SIZE];
|
||||
size_t key_size;
|
||||
|
||||
EVP_AEAD_CTX* aes_ctx;
|
||||
uint64_t dec_sequence;
|
||||
uint64_t enc_sequence;
|
||||
};
|
||||
|
||||
static jlong PairingContext_Constructor(JNIEnv* env, jclass clazz, jboolean isClient, jbyteArray jPassword) {
|
||||
spake2_role_t spake_role;
|
||||
const uint8_t* my_name;
|
||||
const uint8_t* their_name;
|
||||
size_t my_len;
|
||||
size_t their_len;
|
||||
|
||||
if (isClient) {
|
||||
spake_role = kClientRole;
|
||||
my_name = kClientName;
|
||||
my_len = sizeof(kClientName);
|
||||
their_name = kServerName;
|
||||
their_len = sizeof(kServerName);
|
||||
} else {
|
||||
spake_role = kServerRole;
|
||||
my_name = kServerName;
|
||||
my_len = sizeof(kServerName);
|
||||
their_name = kClientName;
|
||||
their_len = sizeof(kClientName);
|
||||
}
|
||||
|
||||
auto spake2_ctx = SPAKE2_CTX_new(spake_role, my_name, my_len, their_name, their_len);
|
||||
if (spake2_ctx == nullptr) {
|
||||
LOGE("Unable to create a SPAKE2 context.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto pswd_size = env->GetArrayLength(jPassword);
|
||||
auto pswd = env->GetByteArrayElements(jPassword, nullptr);
|
||||
|
||||
size_t key_size = 0;
|
||||
uint8_t key[SPAKE2_MAX_MSG_SIZE];
|
||||
int status = SPAKE2_generate_msg(
|
||||
spake2_ctx,
|
||||
key,
|
||||
&key_size,
|
||||
SPAKE2_MAX_MSG_SIZE,
|
||||
reinterpret_cast<uint8_t*>(pswd),
|
||||
pswd_size
|
||||
);
|
||||
if (status != 1 || key_size == 0) {
|
||||
LOGE("Unable to generate the SPAKE2 public key.");
|
||||
|
||||
env->ReleaseByteArrayElements(jPassword, pswd, 0);
|
||||
SPAKE2_CTX_free(spake2_ctx);
|
||||
return 0;
|
||||
}
|
||||
env->ReleaseByteArrayElements(jPassword, pswd, 0);
|
||||
|
||||
auto ctx = reinterpret_cast<PairingContextNative*>(malloc(sizeof(PairingContextNative)));
|
||||
memset(ctx, 0, sizeof(PairingContextNative));
|
||||
ctx->spake2_ctx = spake2_ctx;
|
||||
memcpy(ctx->key, key, SPAKE2_MAX_MSG_SIZE);
|
||||
ctx->key_size = key_size;
|
||||
return reinterpret_cast<jlong>(ctx);
|
||||
}
|
||||
|
||||
static jbyteArray PairingContext_Msg(JNIEnv* env, jobject obj, jlong ptr) {
|
||||
auto ctx = reinterpret_cast<PairingContextNative*>(ptr);
|
||||
jbyteArray our_msg = env->NewByteArray(static_cast<jsize>(ctx->key_size));
|
||||
env->SetByteArrayRegion(our_msg, 0, static_cast<jsize>(ctx->key_size), reinterpret_cast<jbyte*>(ctx->key));
|
||||
return our_msg;
|
||||
}
|
||||
|
||||
static jboolean PairingContext_InitCipher(JNIEnv* env, jobject obj, jlong ptr, jbyteArray jTheirMsg) {
|
||||
auto res = JNI_TRUE;
|
||||
|
||||
auto ctx = reinterpret_cast<PairingContextNative*>(ptr);
|
||||
auto spake2_ctx = ctx->spake2_ctx;
|
||||
auto their_msg_size = env->GetArrayLength(jTheirMsg);
|
||||
|
||||
if (their_msg_size > SPAKE2_MAX_MSG_SIZE) {
|
||||
LOGE("their_msg size [%d] greater then max size [%d].", their_msg_size, SPAKE2_MAX_MSG_SIZE);
|
||||
return JNI_FALSE;
|
||||
}
|
||||
|
||||
auto their_msg = env->GetByteArrayElements(jTheirMsg, nullptr);
|
||||
|
||||
size_t key_material_len = 0;
|
||||
uint8_t key_material[SPAKE2_MAX_KEY_SIZE];
|
||||
int status = SPAKE2_process_msg(
|
||||
spake2_ctx,
|
||||
key_material,
|
||||
&key_material_len,
|
||||
sizeof(key_material),
|
||||
reinterpret_cast<uint8_t*>(their_msg),
|
||||
their_msg_size
|
||||
);
|
||||
|
||||
env->ReleaseByteArrayElements(jTheirMsg, their_msg, 0);
|
||||
|
||||
if (status != 1) {
|
||||
LOGE("Unable to process their public key");
|
||||
return JNI_FALSE;
|
||||
}
|
||||
|
||||
// --------
|
||||
uint8_t key[kHkdfKeyLength];
|
||||
uint8_t info[] = "adb pairing_auth aes-128-gcm key";
|
||||
|
||||
status = HKDF(
|
||||
key,
|
||||
sizeof(key),
|
||||
EVP_sha256(),
|
||||
key_material,
|
||||
key_material_len,
|
||||
nullptr,
|
||||
0,
|
||||
info,
|
||||
sizeof(info) - 1
|
||||
);
|
||||
if (status != 1) {
|
||||
LOGE("HKDF");
|
||||
return JNI_FALSE;
|
||||
}
|
||||
|
||||
ctx->aes_ctx = EVP_AEAD_CTX_new(EVP_aead_aes_128_gcm(), key, sizeof(key), EVP_AEAD_DEFAULT_TAG_LENGTH);
|
||||
|
||||
if (!ctx->aes_ctx) {
|
||||
LOGE("EVP_AEAD_CTX_new");
|
||||
return JNI_FALSE;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
static jbyteArray PairingContext_Encrypt(JNIEnv* env, jobject obj, jlong ptr, jbyteArray jIn) {
|
||||
auto ctx = reinterpret_cast<PairingContextNative*>(ptr);
|
||||
auto aes_ctx = ctx->aes_ctx;
|
||||
|
||||
auto in = env->GetByteArrayElements(jIn, nullptr);
|
||||
auto in_size = env->GetArrayLength(jIn);
|
||||
|
||||
auto out_size = static_cast<size_t>(in_size) + EVP_AEAD_max_overhead(EVP_AEAD_CTX_aead(aes_ctx));
|
||||
auto out = reinterpret_cast<uint8_t*>(malloc(out_size));
|
||||
if (!out) {
|
||||
env->ReleaseByteArrayElements(jIn, in, 0);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto nonce_size = EVP_AEAD_nonce_length(EVP_AEAD_CTX_aead(aes_ctx));
|
||||
auto nonce = reinterpret_cast<uint8_t*>(malloc(nonce_size));
|
||||
if (!nonce) {
|
||||
free(out);
|
||||
env->ReleaseByteArrayElements(jIn, in, 0);
|
||||
return nullptr;
|
||||
}
|
||||
memset(nonce, 0, nonce_size);
|
||||
memcpy(nonce, &ctx->enc_sequence, sizeof(ctx->enc_sequence));
|
||||
|
||||
size_t written_sz;
|
||||
int status = EVP_AEAD_CTX_seal(
|
||||
aes_ctx,
|
||||
out,
|
||||
&written_sz,
|
||||
out_size,
|
||||
nonce,
|
||||
nonce_size,
|
||||
reinterpret_cast<uint8_t*>(in),
|
||||
in_size,
|
||||
nullptr,
|
||||
0
|
||||
);
|
||||
|
||||
env->ReleaseByteArrayElements(jIn, in, 0);
|
||||
free(nonce);
|
||||
|
||||
if (!status) {
|
||||
LOGE("Failed to encrypt (in_len=%d, out_cap=%" PRIuPTR ")", in_size, out_size);
|
||||
free(out);
|
||||
return nullptr;
|
||||
}
|
||||
++ctx->enc_sequence;
|
||||
|
||||
jbyteArray jOut = env->NewByteArray(static_cast<jsize>(written_sz));
|
||||
env->SetByteArrayRegion(jOut, 0, static_cast<jsize>(written_sz), reinterpret_cast<jbyte*>(out));
|
||||
free(out);
|
||||
return jOut;
|
||||
}
|
||||
|
||||
static jbyteArray PairingContext_Decrypt(JNIEnv* env, jobject obj, jlong ptr, jbyteArray jIn) {
|
||||
auto ctx = reinterpret_cast<PairingContextNative*>(ptr);
|
||||
auto aes_ctx = ctx->aes_ctx;
|
||||
|
||||
auto in = env->GetByteArrayElements(jIn, nullptr);
|
||||
auto in_size = env->GetArrayLength(jIn);
|
||||
|
||||
auto out_size = static_cast<size_t>(in_size);
|
||||
auto out = reinterpret_cast<uint8_t*>(malloc(out_size));
|
||||
if (!out) {
|
||||
env->ReleaseByteArrayElements(jIn, in, 0);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto nonce_size = EVP_AEAD_nonce_length(EVP_AEAD_CTX_aead(aes_ctx));
|
||||
auto nonce = reinterpret_cast<uint8_t*>(malloc(nonce_size));
|
||||
if (!nonce) {
|
||||
free(out);
|
||||
env->ReleaseByteArrayElements(jIn, in, 0);
|
||||
return nullptr;
|
||||
}
|
||||
memset(nonce, 0, nonce_size);
|
||||
memcpy(nonce, &ctx->dec_sequence, sizeof(ctx->dec_sequence));
|
||||
|
||||
size_t written_sz;
|
||||
int status = EVP_AEAD_CTX_open(
|
||||
aes_ctx,
|
||||
out,
|
||||
&written_sz,
|
||||
out_size,
|
||||
nonce,
|
||||
nonce_size,
|
||||
reinterpret_cast<uint8_t*>(in),
|
||||
in_size,
|
||||
nullptr,
|
||||
0
|
||||
);
|
||||
|
||||
env->ReleaseByteArrayElements(jIn, in, 0);
|
||||
free(nonce);
|
||||
|
||||
if (!status) {
|
||||
LOGE("Failed to decrypt (in_len=%d, out_cap=%" PRIuPTR ")", in_size, out_size);
|
||||
free(out);
|
||||
return nullptr;
|
||||
}
|
||||
++ctx->dec_sequence;
|
||||
|
||||
jbyteArray jOut = env->NewByteArray(static_cast<jsize>(written_sz));
|
||||
env->SetByteArrayRegion(jOut, 0, static_cast<jsize>(written_sz), reinterpret_cast<jbyte*>(out));
|
||||
free(out);
|
||||
return jOut;
|
||||
}
|
||||
|
||||
static void PairingContext_Destroy(JNIEnv* env, jobject obj, jlong ptr) {
|
||||
auto ctx = reinterpret_cast<PairingContextNative*>(ptr);
|
||||
if (!ctx) return;
|
||||
SPAKE2_CTX_free(ctx->spake2_ctx);
|
||||
if (ctx->aes_ctx) {
|
||||
EVP_AEAD_CTX_free(ctx->aes_ctx);
|
||||
}
|
||||
free(ctx);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
|
||||
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||
JNIEnv* env = nullptr;
|
||||
|
||||
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK)
|
||||
return -1;
|
||||
|
||||
JNINativeMethod methods[] = {
|
||||
{"nativeConstructor", "(Z[B)J", reinterpret_cast<void*>(PairingContext_Constructor)},
|
||||
{"nativeMsg", "(J)[B", reinterpret_cast<void*>(PairingContext_Msg)},
|
||||
{"nativeInitCipher", "(J[B)Z", reinterpret_cast<void*>(PairingContext_InitCipher)},
|
||||
{"nativeEncrypt", "(J[B)[B", reinterpret_cast<void*>(PairingContext_Encrypt)},
|
||||
{"nativeDecrypt", "(J[B)[B", reinterpret_cast<void*>(PairingContext_Decrypt)},
|
||||
{"nativeDestroy", "(J)V", reinterpret_cast<void*>(PairingContext_Destroy)},
|
||||
};
|
||||
|
||||
jclass clazz = env->FindClass("io/github/miuzarte/scrcpyforandroid/nativecore/PairingContext");
|
||||
if (clazz == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (env->RegisterNatives(clazz, methods, sizeof(methods) / sizeof(JNINativeMethod)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
4
app/src/main/jni/adb_pairing.h
Normal file
4
app/src/main/jni/adb_pairing.h
Normal file
@@ -0,0 +1,4 @@
|
||||
#ifndef SCRCPY_FOR_ANDROID_ADB_PAIRING_H
|
||||
#define SCRCPY_FOR_ANDROID_ADB_PAIRING_H
|
||||
|
||||
#endif // SCRCPY_FOR_ANDROID_ADB_PAIRING_H
|
||||
31
app/src/main/jni/logging.h
Normal file
31
app/src/main/jni/logging.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#ifndef _LOGGING_H
|
||||
#define _LOGGING_H
|
||||
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include "android/log.h"
|
||||
|
||||
#ifndef LOG_TAG
|
||||
#define LOG_TAG "ScrcpyForAndroid"
|
||||
#endif
|
||||
|
||||
#ifndef NO_LOG
|
||||
#ifndef NO_DEBUG_LOG
|
||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
|
||||
#else
|
||||
#define LOGD(...)
|
||||
#endif
|
||||
#define LOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
#define PLOGE(fmt, args...) LOGE(fmt " failed with %d: %s", ##args, errno, strerror(errno))
|
||||
#else
|
||||
#define LOGD(...)
|
||||
#define LOGV(...)
|
||||
#define LOGI(...)
|
||||
#define LOGW(...)
|
||||
#define LOGE(...)
|
||||
#define PLOGE(fmt, args...)
|
||||
#endif
|
||||
#endif // _LOGGING_H
|
||||
Reference in New Issue
Block a user