diff --git a/app/build.gradle b/app/build.gradle index dcc9db85..568a8ab4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -121,6 +121,10 @@ android { } packagingOptions { + resources { + // Kadb's Bouncy Castle artifacts repeat the same multi-release JAR manifest. + excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'] + } jniLibs { // 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, @@ -160,6 +164,9 @@ dependencies { implementation 'androidx.compose.material:material-icons-extended' implementation 'io.coil-kt:coil-compose:2.6.0' 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. implementation 'com.termux.termux-app:terminal-view:0.118.0' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ecf4d68c..be31238e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index c337da73..1dc32a10 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -42,6 +42,7 @@ import com.droiddeck.launcher.ui.ProtonRow import com.droiddeck.launcher.core.CpuCores import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus +import com.droiddeck.launcher.core.WirelessAdbFix import com.droiddeck.launcher.ui.CoreRow import com.droiddeck.launcher.ui.PerformancePage import com.droiddeck.launcher.ui.ModeSettingsPage @@ -354,6 +355,31 @@ class MainActivity : ComponentActivity() { }, onRefreshPhantomStatus = { refreshPhantomStatus() }, 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 = { (getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip( ClipData.newPlainText("DroidDeck child-process setting", PhantomProcessLimit.ADB_COMMAND), diff --git a/app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt b/app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt index 29d48c26..9b2fcfef 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt @@ -15,6 +15,7 @@ enum class PhantomProcessStatus { object PhantomProcessLimit { 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" fun read(resolver: ContentResolver, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus { diff --git a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt new file mode 100644 index 00000000..44f0bba7 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt @@ -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() + val seen = mutableSetOf() + 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()}" + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index a2dbe0f6..a294a928 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -213,6 +213,9 @@ class FrontEndActions( val onCheckLatestBuild: () -> Unit = {}, val onRefreshPhantomStatus: () -> 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 onDismissPhantomGate: () -> Unit = {}, 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) { var selected by rememberSaveable { mutableStateOf("steam") } var navOpen by rememberSaveable { mutableStateOf(false) } + var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) } var appToChooseDisplay by remember { mutableStateOf(null) } val colors = MaterialTheme.colorScheme val ctx = LocalContext.current @@ -503,11 +507,21 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com confirmButton = { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { 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") } } }, ) } + if (showWirelessAdbFix) { + WirelessAdbFixDialog( + onDismiss = { showWirelessAdbFix = false }, + onOpenDeveloperOptions = a.onOpenDeveloperOptions, + onPair = a.onWirelessAdbPair, + onFindConnectPort = a.onFindWirelessAdbPort, + onApply = a.onWirelessAdbApply, + ) + } } BackHandler(enabled = navOpen) { navOpen = false } val hasBackTarget = (s.pageKey != null && page != null) || diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixDialog.kt new file mode 100644 index 00000000..f054bd31 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixDialog.kt @@ -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(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 diff --git a/build.gradle b/build.gradle index c081d9d9..70c4e053 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.8.0' - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21' - classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.21' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0' + classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.1.0' } }