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