From 43d3b3f818fd1b637a9a42dbae031b8a76b9aa56 Mon Sep 17 00:00:00 2001
From: Miuzarte <982809597@qq.com>
Date: Thu, 19 Mar 2026 22:24:48 +0800
Subject: [PATCH] feat: adb pair; mDNS discovering service
---
README.md | 11 +-
ScrcpyForAndroid.code-workspace | 11 +-
app/build.gradle.kts | 22 +-
app/src/main/AndroidManifest.xml | 1 +
.../scrcpyforandroid/NativeCoreFacade.kt | 15 +
.../scrcpyforandroid/constants/AppDefaults.kt | 3 +
.../constants/AppPreferenceKeys.kt | 3 +
.../nativecore/AdbMdnsDiscoverer.kt | 127 ++++++++
.../nativecore/AdbPairingTypes.kt | 201 ++++++++++++
.../nativecore/DirectAdbClient.kt | 97 +++++-
.../nativecore/DirectAdbPairingClient.kt | 267 ++++++++++++++++
.../nativecore/NativeAdbService.kt | 44 ++-
.../nativecore/ScrcpyAudioPlayer.kt | 1 +
.../scrcpyforandroid/pages/DevicePage.kt | 159 ++++++++-
.../scrcpyforandroid/pages/MainPage.kt | 26 ++
.../scrcpyforandroid/pages/SettingsPage.kt | 20 ++
.../services/AppSettingsStore.kt | 28 ++
.../services/QuickDeviceStore.kt | 25 ++
.../scrcpyforandroid/widgets/DeviceWidgets.kt | 66 +++-
.../widgets/StatusCardModels.kt | 8 +-
app/src/main/jni/CMakeLists.txt | 17 +
app/src/main/jni/adb_pairing.cpp | 302 ++++++++++++++++++
app/src/main/jni/adb_pairing.h | 4 +
app/src/main/jni/logging.h | 31 ++
24 files changed, 1460 insertions(+), 29 deletions(-)
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbPairingTypes.kt
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt
create mode 100644 app/src/main/jni/CMakeLists.txt
create mode 100644 app/src/main/jni/adb_pairing.cpp
create mode 100644 app/src/main/jni/adb_pairing.h
create mode 100644 app/src/main/jni/logging.h
diff --git a/README.md b/README.md
index 90f7e41..21dadcb 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,13 @@
+## 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
diff --git a/ScrcpyForAndroid.code-workspace b/ScrcpyForAndroid.code-workspace
index 06010f7..67c0246 100644
--- a/ScrcpyForAndroid.code-workspace
+++ b/ScrcpyForAndroid.code-workspace
@@ -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"
}
],
}
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b4e9883..ddcf9da 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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))
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 758ec89..25fe758 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
()
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? {
+ return ioCall { adbService.discoverPairingService(timeoutMs, includeLanDevices) }
+ }
+
+ fun adbDiscoverConnectService(
+ timeoutMs: Long = 12_000,
+ includeLanDevices: Boolean = true,
+ ): Pair? {
+ return ioCall { adbService.discoverConnectService(timeoutMs, includeLanDevices) }
+ }
+
fun adbConnect(host: String, port: Int): Boolean = ioCall { adbService.connect(host, port) }
fun adbDisconnect(): Boolean {
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt
index 0053964..9560cb6 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt
@@ -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
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt
index 69b4832..67443f4 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt
@@ -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"
}
\ No newline at end of file
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt
new file mode 100644
index 0000000..c9d8baa
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbMdnsDiscoverer.kt
@@ -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? {
+ return discoverService(TLS_PAIRING, timeoutMs, includeLanDevices)
+ }
+
+ fun discoverConnectService(timeoutMs: Long, includeLanDevices: Boolean): Pair? {
+ return discoverService(TLS_CONNECT, timeoutMs, includeLanDevices)
+ }
+
+ private fun discoverService(
+ serviceType: String,
+ timeoutMs: Long,
+ includeLanDevices: Boolean,
+ ): Pair? {
+ val resultPort = AtomicInteger(-1)
+ val resultHost = AtomicReference(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"
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbPairingTypes.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbPairingTypes.kt
new file mode 100644
index 0000000..47dbc1d
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AdbPairingTypes.kt
@@ -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?,
+ issuers: Array?,
+ socket: Socket?,
+ ): String = keyAlias
+
+ override fun getCertificateChain(alias: String?): Array? {
+ 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?,
+ ): Array? = null
+
+ override fun getServerAliases(
+ keyType: String?,
+ issuers: Array?,
+ ): Array? = null
+
+ override fun chooseServerAlias(
+ keyType: String?,
+ issuers: Array?,
+ 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?,
+ authType: String?,
+ ) {
+ if (chain.isNullOrEmpty()) return
+ if (authType.isNullOrBlank()) return
+ }
+
+ override fun checkClientTrusted(
+ chain: Array?,
+ authType: String?,
+ socket: Socket?,
+ ) {
+ acceptForPairing(chain, authType)
+ }
+
+ override fun checkClientTrusted(
+ chain: Array?,
+ authType: String?,
+ engine: SSLEngine?,
+ ) {
+ acceptForPairing(chain, authType)
+ }
+
+ override fun checkClientTrusted(chain: Array?, authType: String?) {
+ acceptForPairing(chain, authType)
+ }
+
+ override fun checkServerTrusted(
+ chain: Array?,
+ authType: String?,
+ socket: Socket?,
+ ) {
+ acceptForPairing(chain, authType)
+ }
+
+ override fun checkServerTrusted(
+ chain: Array?,
+ authType: String?,
+ engine: SSLEngine?,
+ ) {
+ acceptForPairing(chain, authType)
+ }
+
+ override fun checkServerTrusted(chain: Array?, authType: String?) {
+ acceptForPairing(chain, authType)
+ }
+
+ override fun getAcceptedIssuers(): Array = 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()
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt
index 1fffb0c..08cd96a 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt
@@ -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? {
+ 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? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
+ return AdbMdnsDiscoverer(context).discoverConnectService(timeoutMs, includeLanDevices)
+ }
+
private fun loadOrCreate(): Pair {
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()
@@ -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() }
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt
new file mode 100644
index 0000000..9b4885d
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbPairingClient.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt
index c00df64..0c31c7a 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt
@@ -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? {
+ 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? {
+ return try {
+ transport.discoverConnectService(timeoutMs, includeLanDevices)
+ } catch (e: Exception) {
+ Log.w(TAG, "discoverConnectService(): failed", e)
+ null
+ }
}
@Synchronized
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt
index 5e3ee36..799a062 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt
@@ -24,6 +24,7 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
@Volatile
private var prepared = false
+
@Volatile
private var released = false
private var packetCount = 0L
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt
index 81ac392..9d75c7e 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt
@@ -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() }
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()
+ 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] 里
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt
index 3085b42..e71e2b0 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt
@@ -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,
)
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt
index 95f876a..63886c8 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt
@@ -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)) }
}
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt
index 2b382d1..7e5a93e 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt
@@ -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,
+ )
}
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt
index 467a44f..3697433 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt
@@ -110,3 +110,28 @@ internal fun updateQuickDeviceNameIfEmpty(
saveQuickDevices(context, quickDevices)
}
}
+
+internal fun replaceQuickDevicePort(
+ context: Context,
+ quickDevices: MutableList,
+ 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)
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt
index e5044fb..3b8bfe3 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt
@@ -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?)? = 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?)?,
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) {
- Card(
- pressFeedbackType = PressFeedbackType.Sink,
- ) {
+ Card {
TextField(
value = lines.joinToString(separator = "\n"),
onValueChange = {},
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt
index b5ffbbc..144a11a 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt
@@ -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
diff --git a/app/src/main/jni/CMakeLists.txt b/app/src/main/jni/CMakeLists.txt
new file mode 100644
index 0000000..5382268
--- /dev/null
+++ b/app/src/main/jni/CMakeLists.txt
@@ -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
+)
diff --git a/app/src/main/jni/adb_pairing.cpp b/app/src/main/jni/adb_pairing.cpp
new file mode 100644
index 0000000..fd82a66
--- /dev/null
+++ b/app/src/main/jni/adb_pairing.cpp
@@ -0,0 +1,302 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#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(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(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(ctx);
+}
+
+static jbyteArray PairingContext_Msg(JNIEnv* env, jobject obj, jlong ptr) {
+ auto ctx = reinterpret_cast(ptr);
+ jbyteArray our_msg = env->NewByteArray(static_cast(ctx->key_size));
+ env->SetByteArrayRegion(our_msg, 0, static_cast(ctx->key_size), reinterpret_cast(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(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(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(ptr);
+ auto aes_ctx = ctx->aes_ctx;
+
+ auto in = env->GetByteArrayElements(jIn, nullptr);
+ auto in_size = env->GetArrayLength(jIn);
+
+ auto out_size = static_cast(in_size) + EVP_AEAD_max_overhead(EVP_AEAD_CTX_aead(aes_ctx));
+ auto out = reinterpret_cast(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(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(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(written_sz));
+ env->SetByteArrayRegion(jOut, 0, static_cast(written_sz), reinterpret_cast(out));
+ free(out);
+ return jOut;
+}
+
+static jbyteArray PairingContext_Decrypt(JNIEnv* env, jobject obj, jlong ptr, jbyteArray jIn) {
+ auto ctx = reinterpret_cast(ptr);
+ auto aes_ctx = ctx->aes_ctx;
+
+ auto in = env->GetByteArrayElements(jIn, nullptr);
+ auto in_size = env->GetArrayLength(jIn);
+
+ auto out_size = static_cast(in_size);
+ auto out = reinterpret_cast(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(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(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(written_sz));
+ env->SetByteArrayRegion(jOut, 0, static_cast(written_sz), reinterpret_cast(out));
+ free(out);
+ return jOut;
+}
+
+static void PairingContext_Destroy(JNIEnv* env, jobject obj, jlong ptr) {
+ auto ctx = reinterpret_cast(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(&env), JNI_VERSION_1_6) != JNI_OK)
+ return -1;
+
+ JNINativeMethod methods[] = {
+ {"nativeConstructor", "(Z[B)J", reinterpret_cast(PairingContext_Constructor)},
+ {"nativeMsg", "(J)[B", reinterpret_cast(PairingContext_Msg)},
+ {"nativeInitCipher", "(J[B)Z", reinterpret_cast(PairingContext_InitCipher)},
+ {"nativeEncrypt", "(J[B)[B", reinterpret_cast(PairingContext_Encrypt)},
+ {"nativeDecrypt", "(J[B)[B", reinterpret_cast(PairingContext_Decrypt)},
+ {"nativeDestroy", "(J)V", reinterpret_cast(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;
+}
diff --git a/app/src/main/jni/adb_pairing.h b/app/src/main/jni/adb_pairing.h
new file mode 100644
index 0000000..a807f0e
--- /dev/null
+++ b/app/src/main/jni/adb_pairing.h
@@ -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
diff --git a/app/src/main/jni/logging.h b/app/src/main/jni/logging.h
new file mode 100644
index 0000000..effd737
--- /dev/null
+++ b/app/src/main/jni/logging.h
@@ -0,0 +1,31 @@
+#ifndef _LOGGING_H
+#define _LOGGING_H
+
+#include
+#include
+#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