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

+## 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