From 9c0ea07c39e6841f4eabb7fe9209ac9aef29cff3 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Fri, 25 Sep 2026 13:49:58 -0400 Subject: [PATCH] Gate Steam sessions on Android process limit --- .../com/droiddeck/launcher/MainActivity.kt | 62 ++++++++++++----- .../droiddeck/launcher/core/DeviceReport.kt | 17 +---- .../launcher/core/PhantomProcessLimit.kt | 67 +++++++++++++++++++ .../droiddeck/launcher/ui/FrontEndScreen.kt | 45 +++++++++++++ 4 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 4c86da537..c337da735 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -2,6 +2,8 @@ package com.droiddeck.launcher import android.Manifest import android.content.Intent +import android.content.ClipData +import android.content.ClipboardManager import java.io.File import android.content.pm.PackageManager import android.hardware.display.DisplayManager @@ -38,6 +40,8 @@ import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.ui.ProtonPage 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.ui.CoreRow import com.droiddeck.launcher.ui.PerformancePage import com.droiddeck.launcher.ui.ModeSettingsPage @@ -112,6 +116,8 @@ class MainActivity : ComponentActivity() { private var noXalia by mutableStateOf(false) private var prootNoSeccomp by mutableStateOf(false) private var phantomWarning by mutableStateOf(null) + private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE) + private var showPhantomGate by mutableStateOf(false) private var directAudio by mutableStateOf(false) private var clientDirectAudio by mutableStateOf(false) private var forceFullscreen by mutableStateOf(true) @@ -230,6 +236,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager + refreshPhantomStatus() theme = SessionPrefs.theme(this) backActionsInverted = SessionPrefs.backActionsInverted(this) setContent { @@ -269,19 +276,21 @@ class MainActivity : ComponentActivity() { buildLabel = BuildConfig.BUILD_LABEL, oscMode = oscMode, controller = controllerSettings, + phantomProcessStatus = phantomProcessStatus, + showPhantomGate = showPhantomGate, ), FrontEndActions( - onPlay = { startSession(Intent(this, SessionActivity::class.java)) }, + onPlay = { startSession(Intent(this, SessionActivity::class.java), steamSession = true) }, // Steam's desktop client as a window on the desktop: under gamescope the // client puts itself into Big Picture whatever it is started with. onPlayDesktopUi = { startSession(Intent(this, SessionActivity::class.java) .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP) - .putExtra(SessionService.EXTRA_STEAM_UI, "desktop")) + .putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true) }, onSteamGame = { g -> - startActivity(Intent(this, SessionActivity::class.java) - .putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.gameId}")) + startSession(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.gameId}"), steamSession = true) }, onDesktop = { startSession(Intent(this, SessionActivity::class.java) @@ -343,6 +352,15 @@ class MainActivity : ComponentActivity() { onCheckLatestBuild = { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/The412Banner/DroidDeck/actions/workflows/build.yml"))) }, + onRefreshPhantomStatus = { refreshPhantomStatus() }, + onOpenDeveloperOptions = { openDeveloperOptions() }, + onCopyPhantomCommand = { + (getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip( + ClipData.newPlainText("DroidDeck child-process setting", PhantomProcessLimit.ADB_COMMAND), + ) + android.widget.Toast.makeText(this, "ADB command copied", android.widget.Toast.LENGTH_SHORT).show() + }, + onDismissPhantomGate = { showPhantomGate = false }, controller = ControllerActions( onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() }, @@ -411,6 +429,7 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() + refreshPhantomStatus() oscMode = SessionPrefs.oscMode(this) refreshController() refreshHomeAppState() @@ -949,17 +968,7 @@ class MainActivity : ComponentActivity() { zinkLazy = SessionPrefs.zinkLazy(this) noXalia = SessionPrefs.noXalia(this) prootNoSeccomp = SessionPrefs.prootNoSeccomp(this) - // Android 12 kills the children an app forks itself once there are more than a handful. - // A session is nothing but those, so where this is on the OS ends the session and no log - // of ours says why. We cannot change a secure setting from here - only say so. - phantomWarning = runCatching { - android.provider.Settings.Global.getString(contentResolver, "settings_enable_monitor_phantom_procs") - }.getOrNull().let { v -> - when (v?.lowercase()) { - "false", "0" -> null - else -> "Android 12 and later kill the extra processes an app starts for itself once there are more than a few, and a session is made of dozens: proot, gamescope, the client and its helpers, Wine. Where that is left on, the client dies with nothing in its log, because nothing in the session did it. Some phones have a \"restrict child processes\" switch in Developer options - turn it off. Otherwise, over adb:\n\n adb shell settings put global settings_enable_monitor_phantom_procs false\n\nThis phone " + (if (v == null) "has not been set either way, so the ROM's default applies." else "currently reports it as on.") - } - } + refreshPhantomStatus() } private fun refreshProtons() { @@ -1016,12 +1025,33 @@ class MainActivity : ComponentActivity() { } /** Starts a session; with no runtime on a non-Adreno, the same warning Setup gives comes first, before any download. */ - private fun startSession(intent: Intent) { + private fun startSession(intent: Intent, steamSession: Boolean = false) { + refreshPhantomStatus() + if (steamSession && PhantomProcessLimit.blocksSteam(phantomProcessStatus)) { + showPhantomGate = true + return + } val warn = installed == null && !com.droiddeck.launcher.core.DeviceSupport.adreno() if (warn && available != null) showNonAdreno = available else startActivity(intent) } + private fun refreshPhantomStatus() { + phantomProcessStatus = PhantomProcessLimit.read(contentResolver) + phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) { + "${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.ADB_COMMAND}" + } else null + } + + private fun openDeveloperOptions() { + runCatching { + startActivity(Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)) + }.onFailure { + startActivity(Intent(android.provider.Settings.ACTION_SETTINGS)) + android.widget.Toast.makeText(this, "Open Developer options in Settings", android.widget.Toast.LENGTH_LONG).show() + } + } + private fun install(release: LinuxRuntimeInstaller.Release) { busy = true failed = false diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 498eb1fd2..9ff0c002a 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -5,7 +5,6 @@ import android.content.Context import android.os.Build import android.os.Environment import android.os.StatFs -import android.provider.Settings import android.util.Log import com.droiddeck.launcher.gpu.LinuxVulkanDriver import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager @@ -162,21 +161,7 @@ object DeviceReport { }) h("Android process limits") - // Android 12 kills "phantom" processes - the children an app forks itself rather than - // starting through the framework - once there are more than a handful of them. A session is - // nothing but those: proot, gamescope, Xwayland, the client, its helpers, Wine, FEX. Where - // this is left on, the OS kills the session and nothing in our logs says why, because - // nothing in the session did it. Some ROMs expose it in Developer options as a - // "restrict child processes" switch; otherwise it is - // adb shell settings put global settings_enable_monitor_phantom_procs false - val phantom = runCatching { - Settings.Global.getString(context.contentResolver, "settings_enable_monitor_phantom_procs") - }.getOrNull() - k("Phantom proc monitor", when (phantom?.lowercase()) { - "false", "0" -> "disabled (good - the OS will not kill the session's children)" - null, "" -> "not set (ROM default, usually ENABLED - see the note below)" - else -> "ENABLED (the OS may kill the session with no log; turn off \"restrict child processes\")" - }) + k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context.contentResolver))) h("Device switch files in Download") for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver", diff --git a/app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt b/app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt new file mode 100644 index 000000000..29d48c26a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt @@ -0,0 +1,67 @@ +package com.droiddeck.launcher.core + +import android.content.ContentResolver +import android.os.Build +import android.provider.Settings + +/** Android's phantom process monitor can kill the many child processes used by a Steam session. */ +enum class PhantomProcessStatus { + NOT_APPLICABLE, + DISABLED, + ENABLED, + UNSET, + UNREADABLE, +} + +object PhantomProcessLimit { + const val ADB_COMMAND = "adb shell 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 { + if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE + val value = try { + Settings.Global.getString(resolver, SETTING) + } catch (_: Exception) { + return PhantomProcessStatus.UNREADABLE + } + return when (value?.trim()?.lowercase()) { + "false", "0" -> PhantomProcessStatus.DISABLED + null, "" -> PhantomProcessStatus.UNSET + else -> PhantomProcessStatus.ENABLED + } + } + + fun blocksSteam(status: PhantomProcessStatus): Boolean = + status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED + + fun title(status: PhantomProcessStatus): String = when (status) { + PhantomProcessStatus.ENABLED -> "Child-process limit is on" + PhantomProcessStatus.UNSET -> "Child-process limit is unset" + PhantomProcessStatus.UNREADABLE -> "Child-process limit could not be checked" + PhantomProcessStatus.DISABLED -> "Child-process limit is off" + PhantomProcessStatus.NOT_APPLICABLE -> "Child-process limit not required" + } + + fun instructions(status: PhantomProcessStatus): String = when (status) { + PhantomProcessStatus.ENABLED -> "Turn off “Restrict child processes” in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again." + PhantomProcessStatus.UNSET -> "This setting is unset, so the ROM default applies and may enforce the limit. Set “Restrict child processes” to off in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again." + PhantomProcessStatus.UNREADABLE -> "DroidDeck could not read the setting. Set “Restrict child processes” to off in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again." + PhantomProcessStatus.DISABLED -> "Steam sessions can start." + PhantomProcessStatus.NOT_APPLICABLE -> "This Android version does not use this limit." + } + + fun gateInstructions(status: PhantomProcessStatus): String = when (status) { + PhantomProcessStatus.ENABLED -> "Turn off “Restrict child processes” in Developer options. If it is not listed, connect this device to a computer with ADB and run the command below." + PhantomProcessStatus.UNSET -> "The ROM default may enforce this limit. Turn off “Restrict child processes” in Developer options. If it is not listed, connect this device to a computer with ADB and run the command below." + PhantomProcessStatus.UNREADABLE -> "DroidDeck could not check this setting. Turn off “Restrict child processes” in Developer options, or connect this device to a computer with ADB and run the command below." + else -> instructions(status) + } + + fun reportValue(status: PhantomProcessStatus): String = when (status) { + PhantomProcessStatus.DISABLED -> "disabled (good - the OS will not kill the session's children)" + PhantomProcessStatus.ENABLED -> "ENABLED (the OS may kill the session with no log; turn off Restrict child processes)" + PhantomProcessStatus.UNSET -> "not set (ROM default applies; DroidDeck requires an explicit disabled value)" + PhantomProcessStatus.UNREADABLE -> "unreadable (DroidDeck could not verify the setting)" + PhantomProcessStatus.NOT_APPLICABLE -> "not applicable before Android 12" + } +} 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 7d7ed8444..896be2147 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -136,6 +136,8 @@ import com.droiddeck.launcher.HomeApp import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.input.SecondScreenDisplay +import com.droiddeck.launcher.core.PhantomProcessLimit +import com.droiddeck.launcher.core.PhantomProcessStatus import com.droiddeck.launcher.session.SessionPrefs import java.io.File import kotlin.math.roundToInt @@ -176,6 +178,8 @@ class FrontEndState( val buildLabel: String = "local", val oscMode: String = SessionPrefs.OSC_AUTO, val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null, + val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE, + val showPhantomGate: Boolean = false, ) class FrontEndActions( @@ -207,6 +211,10 @@ class FrontEndActions( val onAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit = { _, _ -> }, val onBackActionsInverted: (Boolean) -> Unit = {}, val onCheckLatestBuild: () -> Unit = {}, + val onRefreshPhantomStatus: () -> Unit = {}, + val onOpenDeveloperOptions: () -> Unit = {}, + val onCopyPhantomCommand: () -> Unit = {}, + val onDismissPhantomGate: () -> Unit = {}, val controller: ControllerActions? = null, ) @@ -473,6 +481,25 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com onDismiss = { appToChooseDisplay = null }, ) } + if (s.showPhantomGate && PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)) { + androidx.compose.material3.AlertDialog( + onDismissRequest = a.onDismissPhantomGate, + title = { Text("Steam cannot start yet") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(PhantomProcessLimit.title(s.phantomProcessStatus)) + Text(PhantomProcessLimit.gateInstructions(s.phantomProcessStatus)) + Text(PhantomProcessLimit.ADB_COMMAND, style = MaterialTheme.typography.bodySmall) + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + androidx.compose.material3.TextButton(onClick = a.onOpenDeveloperOptions) { Text("Developer options") } + androidx.compose.material3.TextButton(onClick = a.onCopyPhantomCommand) { Text("Copy ADB") } + } + }, + ) + } } BackHandler(enabled = navOpen) { navOpen = false } val hasBackTarget = (s.pageKey != null && page != null) || @@ -999,6 +1026,24 @@ private fun SetupPanel(s: FrontEndState, a: FrontEndActions) { Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { Rise(0) { Eyebrow("Setup") } Rise(1) { Title("Setup") } + if (PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)) { + SettingsGroup("Android process limit") { + Text( + PhantomProcessLimit.title(s.phantomProcessStatus), + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.error, + ) + Text(PhantomProcessLimit.instructions(s.phantomProcessStatus), color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(PhantomProcessLimit.ADB_COMMAND, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryButton("Developer options", onClick = a.onOpenDeveloperOptions) + SecondaryButton("Check again", onClick = a.onRefreshPhantomStatus) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryButton("Copy ADB command", onClick = a.onCopyPhantomCommand) + } + } + } SettingsGroup("Launcher tools") { ActionRow("Files", "Browse and manage files", "Open", a.onFiles) ActionRow("Compatibility tools", "Install ARM64 Proton builds", "Manage", a.onProtons)