Add wireless ADB process-limit repair

This commit is contained in:
xXJsonDeruloXx committed 2026-09-25 14:35:37 -04:00
1 parent 019949847a
commit ddd4a0c24d
8 files changed
+296 -2

No files matched your search

+7
View File
@@ -121,6 +121,10 @@ android {
} }
packagingOptions { packagingOptions {
resources {
// Kadb's Bouncy Castle artifacts repeat the same multi-release JAR manifest.
excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
}
jniLibs { jniLibs {
// The PulseAudio daemon is executed from the native library directory and its // The PulseAudio daemon is executed from the native library directory and its
// libraries are read out of the apk by the class loader: both want the old, // libraries are read out of the apk by the class loader: both want the old,
@@ -160,6 +164,9 @@ dependencies {
implementation 'androidx.compose.material:material-icons-extended' implementation 'androidx.compose.material:material-icons-extended'
implementation 'io.coil-kt:coil-compose:2.6.0' implementation 'io.coil-kt:coil-compose:2.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// ADB-over-Wi-Fi pairing and authenticated shell client. Users still approve pairing from
// Android's Wireless debugging screen; DroidDeck only runs its one fixed setting command.
implementation 'com.flyfishxu:kadb-android:1.2.1'
// Android terminal view/emulator used by the in-session secondary-display Linux shell. // Android terminal view/emulator used by the in-session secondary-display Linux shell.
implementation 'com.termux.termux-app:terminal-view:0.118.0' implementation 'com.termux.termux-app:terminal-view:0.118.0'
} }
+1
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Session logs go to Downloads so a failed run can be handed over as one folder. --> <!-- Session logs go to Downloads so a failed run can be handed over as one folder. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
@@ -42,6 +42,7 @@ import com.droiddeck.launcher.ui.ProtonRow
import com.droiddeck.launcher.core.CpuCores import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.core.WirelessAdbFix
import com.droiddeck.launcher.ui.CoreRow import com.droiddeck.launcher.ui.CoreRow
import com.droiddeck.launcher.ui.PerformancePage import com.droiddeck.launcher.ui.PerformancePage
import com.droiddeck.launcher.ui.ModeSettingsPage import com.droiddeck.launcher.ui.ModeSettingsPage
@@ -354,6 +355,31 @@ class MainActivity : ComponentActivity() {
}, },
onRefreshPhantomStatus = { refreshPhantomStatus() }, onRefreshPhantomStatus = { refreshPhantomStatus() },
onOpenDeveloperOptions = { openDeveloperOptions() }, onOpenDeveloperOptions = { openDeveloperOptions() },
onWirelessAdbPair = { host, port, code, complete ->
Thread({
val result = runCatching {
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@MainActivity, host, port, code) }
}
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless ADB pairing failed" }
ui.post { complete(error) }
}, "wireless-adb-pair").start()
},
onFindWirelessAdbPort = { host, complete ->
Thread({
val port = WirelessAdbFix.findConnectPort(this@MainActivity, host)
ui.post { complete(port) }
}, "wireless-adb-discovery").start()
},
onWirelessAdbApply = { host, port, complete ->
Thread({
val result = runCatching { WirelessAdbFix.disableChildProcessLimit(this@MainActivity, host, port) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless ADB command failed" }
ui.post {
if (error == null) refreshPhantomStatus()
complete(error)
}
}, "wireless-adb-fix").start()
},
onCopyPhantomCommand = { onCopyPhantomCommand = {
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip( (getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText("DroidDeck child-process setting", PhantomProcessLimit.ADB_COMMAND), ClipData.newPlainText("DroidDeck child-process setting", PhantomProcessLimit.ADB_COMMAND),
@@ -15,6 +15,7 @@ enum class PhantomProcessStatus {
object PhantomProcessLimit { object PhantomProcessLimit {
const val ADB_COMMAND = "adb shell settings put global settings_enable_monitor_phantom_procs false" const val ADB_COMMAND = "adb shell settings put global settings_enable_monitor_phantom_procs false"
const val SHELL_COMMAND = "settings put global settings_enable_monitor_phantom_procs false"
private const val SETTING = "settings_enable_monitor_phantom_procs" private const val SETTING = "settings_enable_monitor_phantom_procs"
fun read(resolver: ContentResolver, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus { fun read(resolver: ContentResolver, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
@@ -0,0 +1,129 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import com.flyfishxu.kadb.Kadb
import com.flyfishxu.kadb.cert.KadbCert
import java.io.File
import java.net.InetAddress
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.ArrayDeque
/** A narrowly scoped Wireless debugging client for the child-process setting repair. */
object WirelessAdbFix {
private const val CERT_FILE = "wireless-adb-cert.pem"
private const val KEY_FILE = "wireless-adb-key.pem"
@Synchronized
private fun loadOrCreateIdentity(context: Context) {
val directory = context.noBackupFilesDir
val cert = File(directory, CERT_FILE)
val key = File(directory, KEY_FILE)
if (cert.isFile && key.isFile) {
KadbCert.set(cert.readBytes(), key.readBytes())
return
}
val identity = KadbCert.get(notAfter = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(3650))
KadbCert.set(identity.first, identity.second)
writeAtomically(cert, identity.first)
writeAtomically(key, identity.second)
}
private fun writeAtomically(destination: File, contents: ByteArray) {
val temporary = File(destination.parentFile, "${destination.name}.tmp")
temporary.writeBytes(contents)
check(temporary.renameTo(destination)) { "Could not save Wireless debugging identity" }
}
suspend fun pair(context: Context, host: String, port: Int, pairingCode: String) {
loadOrCreateIdentity(context.applicationContext)
Kadb.pair(host, port, pairingCode, "DroidDeck")
}
/** Finds this paired device's separate TLS connection port from Android's ADB mDNS record. */
fun findConnectPort(context: Context, pairedHost: String, timeoutSeconds: Long = 12): Int? {
val manager = context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager
val expectedAddress = runCatching { InetAddress.getByName(pairedHost).address }.getOrNull() ?: return null
val resolvedPort = AtomicInteger(-1)
val finished = AtomicBoolean(false)
val latch = CountDownLatch(1)
val queue = ArrayDeque<NsdServiceInfo>()
val seen = mutableSetOf<String>()
var resolving = false
val lock = Any()
lateinit var resolveNext: () -> Unit
resolveNext = {
val next = synchronized(lock) {
if (finished.get() || resolving || queue.isEmpty()) null
else queue.removeFirst().also { resolving = true }
}
if (next != null) {
val listener = object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
synchronized(lock) { resolving = false }
resolveNext()
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
val address = serviceInfo.host?.address
if (address != null && address.contentEquals(expectedAddress) && finished.compareAndSet(false, true)) {
resolvedPort.set(serviceInfo.port)
latch.countDown()
} else {
synchronized(lock) { resolving = false }
resolveNext()
}
}
}
runCatching { manager.resolveService(next, listener) }.onFailure {
synchronized(lock) { resolving = false }
resolveNext()
}
}
}
val discoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) = Unit
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (serviceInfo.serviceType?.contains("_adb-tls-connect._tcp") == true) {
synchronized(lock) {
if (seen.add(serviceInfo.serviceName)) queue.addLast(serviceInfo)
}
resolveNext()
}
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
override fun onDiscoveryStopped(serviceType: String) { latch.countDown() }
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { latch.countDown() }
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { latch.countDown() }
}
try {
manager.discoverServices("_adb-tls-connect._tcp.", NsdManager.PROTOCOL_DNS_SD, discoveryListener)
latch.await(timeoutSeconds, TimeUnit.SECONDS)
} catch (_: Exception) {
return null
} finally {
runCatching { manager.stopServiceDiscovery(discoveryListener) }
}
return resolvedPort.get().takeIf { it in 1..65535 }
}
fun disableChildProcessLimit(context: Context, host: String, port: Int) {
loadOrCreateIdentity(context.applicationContext)
Kadb.create(host, port).use { adb ->
val change = adb.shell(PhantomProcessLimit.SHELL_COMMAND)
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
val result = adb.shell("settings get global settings_enable_monitor_phantom_procs")
check(result.exitCode == 0 && result.output.trim() == "false") {
"Android did not confirm the child-process limit was disabled: ${result.allOutput.trim()}"
}
}
}
}
@@ -213,6 +213,9 @@ class FrontEndActions(
val onCheckLatestBuild: () -> Unit = {}, val onCheckLatestBuild: () -> Unit = {},
val onRefreshPhantomStatus: () -> Unit = {}, val onRefreshPhantomStatus: () -> Unit = {},
val onOpenDeveloperOptions: () -> Unit = {}, val onOpenDeveloperOptions: () -> Unit = {},
val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless ADB is unavailable") },
val onFindWirelessAdbPort: (String, (Int?) -> Unit) -> Unit = { _, done -> done(null) },
val onWirelessAdbApply: (String, Int, (String?) -> Unit) -> Unit = { _, _, done -> done("Wireless ADB is unavailable") },
val onCopyPhantomCommand: () -> Unit = {}, val onCopyPhantomCommand: () -> Unit = {},
val onDismissPhantomGate: () -> Unit = {}, val onDismissPhantomGate: () -> Unit = {},
val controller: ControllerActions? = null, val controller: ControllerActions? = null,
@@ -351,6 +354,7 @@ fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -
private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus) { private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus) {
var selected by rememberSaveable { mutableStateOf("steam") } var selected by rememberSaveable { mutableStateOf("steam") }
var navOpen by rememberSaveable { mutableStateOf(false) } var navOpen by rememberSaveable { mutableStateOf(false) }
var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) }
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) } var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
val colors = MaterialTheme.colorScheme val colors = MaterialTheme.colorScheme
val ctx = LocalContext.current val ctx = LocalContext.current
@@ -503,11 +507,21 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
confirmButton = { confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
androidx.compose.material3.TextButton(onClick = a.onOpenDeveloperOptions) { Text("Developer options") } androidx.compose.material3.TextButton(onClick = a.onOpenDeveloperOptions) { Text("Developer options") }
androidx.compose.material3.TextButton(onClick = { showWirelessAdbFix = true }) { Text("Fix over Wi-Fi") }
androidx.compose.material3.TextButton(onClick = a.onCopyPhantomCommand) { Text("Copy ADB") } androidx.compose.material3.TextButton(onClick = a.onCopyPhantomCommand) { Text("Copy ADB") }
} }
}, },
) )
} }
if (showWirelessAdbFix) {
WirelessAdbFixDialog(
onDismiss = { showWirelessAdbFix = false },
onOpenDeveloperOptions = a.onOpenDeveloperOptions,
onPair = a.onWirelessAdbPair,
onFindConnectPort = a.onFindWirelessAdbPort,
onApply = a.onWirelessAdbApply,
)
}
} }
BackHandler(enabled = navOpen) { navOpen = false } BackHandler(enabled = navOpen) { navOpen = false }
val hasBackTarget = (s.pageKey != null && page != null) || val hasBackTarget = (s.pageKey != null && page != null) ||
@@ -0,0 +1,116 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
@Composable
fun WirelessAdbFixDialog(
onDismiss: () -> Unit,
onOpenDeveloperOptions: () -> Unit,
onPair: (String, Int, String, (String?) -> Unit) -> Unit,
onFindConnectPort: (String, (Int?) -> Unit) -> Unit,
onApply: (String, Int, (String?) -> Unit) -> Unit,
) {
var step by rememberSaveable { mutableStateOf(0) }
var host by rememberSaveable { mutableStateOf("") }
var pairingPort by rememberSaveable { mutableStateOf("") }
var pairingCode by rememberSaveable { mutableStateOf("") }
var connectPort by rememberSaveable { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { if (!busy) onDismiss() },
title = { Text("Fix over Wi-Fi") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
when (step) {
0 -> {
Text("Enable Developer options and Wireless debugging on this device, and connect to Wi-Fi. Keep DroidDeck visible beside the pairing-code screen, such as in split screen. Android may close the code if you leave that screen.")
Text("Enter the IP address and pairing port shown with the code.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(host, { host = it.trim() }, label = { Text("Device IP address") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(pairingPort, { pairingPort = it.filter(Char::isDigit).take(5) }, label = { Text("Pairing port") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth())
OutlinedTextField(pairingCode, { pairingCode = it.filter(Char::isDigit).take(6) }, label = { Text("Pairing code") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth())
}
1 -> {
Text("Paired. Return to the Wireless debugging screen and enter the current IP address and port shown there. This connect port is different from the pairing port.")
OutlinedTextField(host, { host = it.trim() }, label = { Text("Device IP address") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(connectPort, { connectPort = it.filter(Char::isDigit).take(5) }, label = { Text("Wireless debugging port") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth())
}
else -> Text(message ?: "Android confirmed the child-process limit is disabled. You can start Steam now.")
}
message?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall) }
if (busy) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
CircularProgressIndicator()
Text(if (step == 0) "Pairing…" else "Applying and checking…")
}
}
},
confirmButton = {
when (step) {
0 -> TextButton(enabled = !busy && host.isNotBlank() && validPort(pairingPort) && pairingCode.length >= 6, onClick = {
busy = true
message = null
onPair(host, pairingPort.toInt(), pairingCode) { error ->
busy = false
pairingCode = ""
if (error == null) { step = 1; message = null } else message = error
if (error == null) {
busy = true
message = "Paired. Finding this device’s Wireless debugging port…"
onFindConnectPort(host) { discoveredPort ->
if (discoveredPort == null) {
busy = false
message = "Automatic discovery did not find the device. Enter the current Wireless debugging port below."
} else {
connectPort = discoveredPort.toString()
message = "Device found. Applying the setting…"
onApply(host, discoveredPort) { applyError ->
busy = false
if (applyError == null) { step = 2; message = null }
else message = "Could not apply automatically. Check the port below and retry. $applyError"
}
}
}
}
}
}) { Text("Pair") }
1 -> TextButton(enabled = !busy && validPort(connectPort), onClick = {
busy = true
message = null
onApply(host, connectPort.toInt()) { error ->
busy = false
if (error == null) { step = 2; message = null } else message = error
}
}) { Text("Disable child-process limit") }
else -> TextButton(onClick = onDismiss) { Text("Done") }
}
},
dismissButton = {
Row {
if (step < 2) TextButton(enabled = !busy, onClick = onOpenDeveloperOptions) { Text("Developer options") }
TextButton(enabled = !busy, onClick = onDismiss) { Text(if (step == 2) "Close" else "Cancel") }
}
},
)
}
private fun validPort(value: String): Boolean = value.toIntOrNull()?.let { it in 1..65535 } == true
+2 -2
View File
@@ -6,7 +6,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.8.0' classpath 'com.android.tools.build:gradle:8.8.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0'
classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.21' classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.1.0'
} }
} }