Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 36f0b02573 fix: use Android's 300 ms double-tap window for Back so the single-press action comes sooner 2026-09-29 02:10:33 -04:00
Kurt Himebauch 24c250fdb4 fix: send wl_keyboard modifiers so Shift and Caps Lock produce capitals (#71) 2026-09-29 02:10:01 -04:00
Kurt Himebauch d4636afa97 fix: give the QAM chord a Guide lead that stretches with slow frames so A is not taken as a press (#70)
* fix: give the QAM chord a Guide lead that stretches with slow frames so A is not taken as a press

* fix: start the QAM Guide lead at 80 ms and stretch it only when frames are slow

* fix: set the QAM Guide lead minimum to 80 ms

* fix: use Android's 300 ms double-tap window for Back so the single-press action comes sooner

* fix: keep the 500 ms Back double-press window
2026-09-29 02:01:21 -04:00
Kurt Himebauch 32930e704e feat: flood out of Stop and back into the launch button on quit (#72)
* feat: flood out of Stop and back into the launch button on quit

* refactor: drop the stop flood's warble to match the launch
2026-09-29 01:53:13 -04:00
Kurt Himebauch a8b18947a1 perf: cache the Steam client's repeated path lookups to cut startup from 31s to 6s (#69) 2026-09-28 23:18:48 -04:00
Kurt Himebauch 7c422577b6 fix: let the Steam client shut down cleanly when a session stops (#68) 2026-09-28 22:12:19 -04:00
Kurt Himebauch 1cc1ee994d feat: pair wireless debugging from a notification for the child-process gate (#67)
* feat: pair wireless debugging from a notification for the child-process gate

* fix: pair over loopback, handle Android 12 and 14 child-process routes, use D logo for notifications

* fix: read the adb tls port directly before falling back to mdns

* fix: end pairing on the same notification so progress never lingers
2026-09-28 21:37:10 -04:00
20 changed files with 1126 additions and 157 deletions

No files matched your search

+3
View File
@@ -130,6 +130,9 @@
<service
android:name=".runtime.RuntimeInstallService"
android:exported="false" />
<service
android:name=".core.WirelessAdbPairingService"
android:exported="false" />
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
<provider
android:name="androidx.core.content.FileProvider"
+40 -3
View File
@@ -466,6 +466,12 @@ static struct seat_pointer g_ptrs[MAX_PTRS];
static int g_nptrs;
static struct seat_keyboard g_kbs[MAX_PTRS];
static int g_nkbs;
/* Modifier state in the keymap's real-modifier bits (Shift=0x1, Lock=0x2, Control=0x4, Mod1=0x8,
* Mod4=0x40). A client of the wayland backend - gamescope, labwc - takes modifiers only from
* wl_keyboard.modifiers and ignores what a Shift key event does to its xkb state, so without this
* every capital arrived lowercase. */
static uint32_t g_mods_depressed, g_mods_locked;
static uint32_t g_mod_keys_held; /* one bit per modifier key below, so a repeat press is harmless */
static struct seat_touch g_touches[MAX_PTRS];
static int g_ntouches;
@@ -2370,8 +2376,8 @@ static void keyboard_focus(struct wl_resource *target) {
if (sk->focus == target) continue;
sk->focus = target;
wl_keyboard_send_enter(sk->kb, wl_display_next_serial(g_display), target, &keys);
/* Baseline modifiers = none; Shift/Ctrl arrive as their own key events. */
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display), 0, 0, 0, 0);
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display),
g_mods_depressed, 0, g_mods_locked, 0);
}
wl_array_release(&keys);
banner_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */
@@ -3068,7 +3074,34 @@ static void scroll_event(int steps) {
wl_display_flush_clients(g_display);
}
/* Folds a key into the modifier state; returns whether the state changed. */
static int update_modifiers(uint32_t evdev, int pressed) {
static const struct { uint32_t evdev, mask; } mod_keys[] = {
{ 42, 0x1 }, { 54, 0x1 }, /* Shift L/R */
{ 29, 0x4 }, { 97, 0x4 }, /* Control L/R */
{ 56, 0x8 }, { 100, 0x8 }, /* Alt L/R, both Mod1 in this keymap */
{ 125, 0x40 }, { 126, 0x40 }, /* Super L/R */
};
uint32_t depressed = g_mods_depressed, locked = g_mods_locked;
if (evdev == 58) { /* Caps Lock toggles on press */
if (pressed) locked ^= 0x2;
} else {
int i, n = (int)(sizeof(mod_keys) / sizeof(mod_keys[0]));
for (i = 0; i < n && mod_keys[i].evdev != evdev; i++) {}
if (i == n) return 0;
if (pressed) g_mod_keys_held |= 1u << i; else g_mod_keys_held &= ~(1u << i);
depressed = 0;
for (int j = 0; j < n; j++) if (g_mod_keys_held & (1u << j)) depressed |= mod_keys[j].mask;
}
if (depressed == g_mods_depressed && locked == g_mods_locked) return 0;
g_mods_depressed = depressed;
g_mods_locked = locked;
return 1;
}
static void key_event(uint32_t evdev, int pressed) {
/* Tracked even with nothing to deliver to, so a Shift released unseen is not left held. */
int mods_changed = update_modifiers(evdev, pressed);
/* Keys go to the program window the user last clicked (else the topmost non-shell window),
* never to Wine's desktop surface: winewayland hands each key to the hwnd of the surface that
* holds keyboard focus, and a key handed to explorer's desktop hwnd is queued to explorer's
@@ -3088,9 +3121,13 @@ static void key_event(uint32_t evdev, int pressed) {
struct seat_keyboard *sk;
if (!keyboard_for(client)) return;
keyboard_focus(target->resource);
for_each_keyboard_of(client, sk)
for_each_keyboard_of(client, sk) {
wl_keyboard_send_key(sk->kb, wl_display_next_serial(g_display), now_ms(), evdev,
pressed ? WL_KEYBOARD_KEY_STATE_PRESSED : WL_KEYBOARD_KEY_STATE_RELEASED);
if (mods_changed)
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display),
g_mods_depressed, 0, g_mods_locked, 0);
}
wl_display_flush_clients(g_display);
}
@@ -45,6 +45,7 @@ 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.core.WirelessAdbPairingService
import com.droiddeck.launcher.ui.CoreRow
import com.droiddeck.launcher.ui.PerformancePage
import com.droiddeck.launcher.ui.ModeSettingsPage
@@ -88,6 +89,9 @@ class MainActivity : ComponentActivity() {
private var flood by mutableStateOf<PendingFlood?>(null)
private var floodProgress by androidx.compose.runtime.mutableFloatStateOf(0f)
private class PendingFlood(val from: androidx.compose.ui.geometry.Rect, val intent: Intent)
/** A session stopped behind a flood: its blue, drawn back into the button it started from (FloodReturn). */
private var returning by mutableStateOf<ReturningFlood?>(null)
private class ReturningFlood(val color: Int, val to: androidx.compose.ui.geometry.Rect?)
private var ready by mutableStateOf(false)
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var busy by mutableStateOf(false)
@@ -431,6 +435,12 @@ class MainActivity : ComponentActivity() {
android.widget.Toast.makeText(this, "ADB command copied", android.widget.Toast.LENGTH_SHORT).show()
},
onDismissPhantomGate = { showPhantomGate = false },
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
onOpenNotificationSettings = {
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
},
controller = ControllerActions(
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
@@ -474,6 +484,13 @@ class MainActivity : ComponentActivity() {
)
if (showCredits) CreditsDialog { showCredits = false }
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
returning?.let { r ->
com.droiddeck.launcher.ui.FloodReturn(androidx.compose.ui.graphics.Color(r.color), r.to, onProgress = { floodProgress = it }) {
returning = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
}
}
}
}
@@ -550,6 +567,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
com.droiddeck.launcher.ui.Motion.refresh(this)
// Back from a session stopped behind a flood: open on its blue, before the first frame.
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
}
refreshPhantomStatus()
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
@@ -585,6 +606,7 @@ class MainActivity : ComponentActivity() {
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
flood = null
returning = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
super.onStop()
@@ -1040,15 +1062,18 @@ class MainActivity : ComponentActivity() {
}
private fun refreshPhantomStatus() {
phantomProcessStatus = PhantomProcessLimit.read(contentResolver)
phantomProcessStatus = PhantomProcessLimit.read(this)
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.ADB_COMMAND}"
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.adbCommand()}"
} else null
}
private fun openDeveloperOptions(displayId: Int?) {
val highlight = if (PhantomProcessLimit.hasDeveloperToggle() &&
WirelessAdbPairingService.stage.value == WirelessAdbPairingService.Stage.Idle) null else "toggle_adb_wireless"
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.apply { if (highlight != null) putExtra(":settings:fragment_args_key", highlight) }
try {
if (displayId == null) startActivity(intent)
else startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(displayId).toBundle())
@@ -29,6 +29,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
@@ -144,6 +146,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
/** A stop growing out of the dialog's Stop (overlay px); the session ends once it covers the screen. */
private var quitFrom by mutableStateOf<androidx.compose.ui.geometry.Rect?>(null)
/** Left behind that flood: the front end opens on its blue, so no sink of our own. */
private var quitFlooded = false
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var drawerPage by mutableIntStateOf(0)
@@ -394,6 +400,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onStopFrom = { onScreen ->
drawerOpen = false
if (onScreen == null || com.droiddeck.launcher.ui.Motion.scale == 0f || quitFrom != null) {
if (quitFrom == null) { SessionService.stop(this@SessionActivity); finish() }
} else {
val at = IntArray(2).also { sessionOverlay.getLocationOnScreen(it) }
quitFrom = onScreen.translate(-at[0].toFloat(), -at[1].toFloat())
}
},
onClose = { drawerOpen = false },
components = drawerComponents,
onComponentsRefresh = { refreshDrawerComponents() },
@@ -403,6 +418,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
title = pausedTitle(),
onResume = { SessionService.resume(this@SessionActivity) },
)
quitFrom?.let { from ->
val error = androidx.compose.material3.MaterialTheme.colorScheme.error
com.droiddeck.launcher.ui.LaunchFlood(
from, onProgress = {},
fromColors = error to androidx.compose.ui.graphics.lerp(error, androidx.compose.ui.graphics.Color.Black, 0.12f),
cornerAtRest = 20.dp,
) { finishFlooded() }
}
}
}
}
@@ -437,7 +460,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
performBackAction(double = false)
}
pendingBackAction = pending
uiHandler.postDelayed(pending, BACK_DOUBLE_PRESS_TIMEOUT_MS)
// Android's own double-tap window (300 ms): the single action waits it out, so it is felt.
uiHandler.postDelayed(pending, android.view.ViewConfiguration.getDoubleTapTimeout().toLong())
}
private fun performBackAction(double: Boolean) {
@@ -1554,16 +1578,27 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onDestroy()
}
/** The screen is all blue: the session stops and the front end opens on the same blue (FloodReturn). */
private fun finishFlooded() {
if (isFinishing) return
val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal
com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb())
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb()))
quitFlooded = true
SessionService.stop(this)
finish()
}
/** Leaving the session sinks its surface back down onto the front end. */
override fun finish() {
super.finish()
overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
if (quitFlooded) overridePendingTransition(0, 0)
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
}
companion object {
private const val TAG = "SessionActivity"
private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION
private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L
private const val CURSOR_PAD_HOLD_MS = 1200L
private const val DRAWER_HAT_THRESHOLD = 0.5f
private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f
@@ -162,7 +162,7 @@ object DeviceReport {
})
h("Android process limits")
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context.contentResolver)))
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
h("Device switch files in Download")
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
@@ -1,6 +1,6 @@
package com.droiddeck.launcher.core
import android.content.ContentResolver
import android.content.Context
import android.os.Build
import android.provider.Settings
@@ -14,17 +14,53 @@ 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 adbCommand(enabled: Boolean): String =
"adb shell settings put global $SETTING ${if (enabled) "true" else "false"}"
private const val MAX_PHANTOM = "2147483647"
private const val PREFS = "phantom-process-limit"
private const val PREF_ANDROID_12_OFF = "android12-off"
fun read(resolver: ContentResolver, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
fun usesDeviceConfig(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk == Build.VERSION_CODES.S
fun shellCommands(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): List<String> = when {
usesDeviceConfig(sdk) && enabled -> listOf(
"device_config delete activity_manager max_phantom_processes",
"device_config set_sync_disabled_for_tests none",
)
usesDeviceConfig(sdk) -> listOf(
"device_config set_sync_disabled_for_tests persistent",
"device_config put activity_manager max_phantom_processes $MAX_PHANTOM",
)
else -> listOf("settings put global $SETTING ${if (enabled) "true" else "false"}")
}
fun verifyCommand(sdk: Int = Build.VERSION.SDK_INT): String =
if (usesDeviceConfig(sdk)) "device_config get activity_manager max_phantom_processes"
else "settings get global $SETTING"
fun verified(output: String, enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): Boolean {
val value = output.trim()
return if (usesDeviceConfig(sdk)) (value == MAX_PHANTOM) != enabled
else value == if (enabled) "true" else "false"
}
fun adbCommand(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): String =
shellCommands(enabled, sdk).joinToString(" && ") { "adb shell $it" }
fun adbCommand(): String = adbCommand(false)
fun rememberAndroid12(context: Context, off: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(PREF_ANDROID_12_OFF, off).apply()
}
fun read(context: Context, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE
if (usesDeviceConfig(sdk)) {
val off = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(PREF_ANDROID_12_OFF, false)
return if (off) PhantomProcessStatus.DISABLED else PhantomProcessStatus.UNREADABLE
}
val global = try {
Settings.Global.getString(resolver, SETTING)
Settings.Global.getString(context.contentResolver, SETTING)
} catch (_: Exception) {
return PhantomProcessStatus.UNREADABLE
}
@@ -57,6 +93,8 @@ object PhantomProcessLimit {
null
}
fun hasDeveloperToggle(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
fun blocksSteam(status: PhantomProcessStatus): Boolean =
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
@@ -69,20 +107,20 @@ object PhantomProcessLimit {
}
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.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence() + " If that is not possible, 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."
PhantomProcessStatus.UNSET -> "The ROM default may enforce this limit. Explicitly turn off “Restrict child processes” in Developer options."
PhantomProcessStatus.UNREADABLE -> "DroidDeck could not check this setting. Turn off “Restrict child processes” in Developer options."
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence()
else -> instructions(status)
}
fun fixSentence(sdk: Int = Build.VERSION.SDK_INT): String =
if (hasDeveloperToggle(sdk)) "Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards."
else "This Android version has no switch for it. Use Wireless debugging to change it from this device."
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)"
@@ -6,7 +6,9 @@ import android.net.nsd.NsdServiceInfo
import com.flyfishxu.kadb.Kadb
import com.flyfishxu.kadb.cert.KadbCert
import java.io.File
import java.net.Inet4Address
import java.net.InetAddress
import java.net.NetworkInterface
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@@ -19,6 +21,7 @@ object WirelessAdbFix {
private const val KEY_FILE = "wireless-adb-key.pem"
private const val HOST_FILE = "wireless-adb-host.txt"
private const val CONNECTION_FILE = "wireless-adb-connection.txt"
const val LOOPBACK = "127.0.0.1"
private data class SavedConnection(val host: String, val port: Int?)
@@ -76,9 +79,27 @@ object WirelessAdbFix {
}
/** Finds this paired device's separate TLS connection port from Android's ADB mDNS record. */
fun isLocalAddress(address: InetAddress): Boolean = runCatching {
if (address.isLoopbackAddress) return true
NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().any { network ->
network.inetAddresses.toList().any { it.address.contentEquals(address.address) }
}
}.getOrDefault(address is Inet4Address)
fun localConnectPort(context: Context): Int? =
tlsPortProperty()
?: findConnectPort(context, LOOPBACK)
private fun tlsPortProperty(): Int? = runCatching {
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
.invoke(null, "service.adb.tls.port") as? String
}.getOrNull()?.trim()?.toIntOrNull()?.takeIf { it in 1..65535 }
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 paired = runCatching { InetAddress.getByName(pairedHost) }.getOrNull() ?: return null
val matches: (InetAddress) -> Boolean =
if (paired.isLoopbackAddress) ::isLocalAddress else { address -> address.address.contentEquals(paired.address) }
val resolvedPort = AtomicInteger(-1)
val finished = AtomicBoolean(false)
val latch = CountDownLatch(1)
@@ -101,8 +122,8 @@ object WirelessAdbFix {
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
val address = serviceInfo.host?.address
if (address != null && address.contentEquals(expectedAddress) && finished.compareAndSet(false, true)) {
val address = serviceInfo.host
if (address != null && matches(address) && finished.compareAndSet(false, true)) {
resolvedPort.set(serviceInfo.port)
latch.countDown()
} else {
@@ -148,14 +169,16 @@ object WirelessAdbFix {
fun setChildProcessLimit(context: Context, host: String, port: Int, enabled: Boolean) {
loadOrCreateIdentity(context.applicationContext)
Kadb.create(host, port).use { adb ->
val value = if (enabled) "true" else "false"
val change = adb.shell("settings put global settings_enable_monitor_phantom_procs $value")
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() == value) {
PhantomProcessLimit.shellCommands(enabled).forEach { command ->
val change = adb.shell(command)
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
}
val result = adb.shell(PhantomProcessLimit.verifyCommand())
check(result.exitCode == 0 && PhantomProcessLimit.verified(result.output, enabled)) {
"Android did not confirm the child-process limit was changed: ${result.allOutput.trim()}"
}
}
if (PhantomProcessLimit.usesDeviceConfig()) PhantomProcessLimit.rememberAndroid12(context, !enabled)
saveConnection(context, host, port)
}
@@ -0,0 +1,270 @@
package com.droiddeck.launcher.core
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.RemoteInput
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class WirelessAdbPairingService : Service() {
sealed interface Stage {
data object Idle : Stage
data object Waiting : Stage
data class CodeNeeded(val error: String? = null) : Stage
data class Working(val step: String) : Stage
data object Done : Stage
data class Failed(val error: String) : Stage
}
private val main = Handler(Looper.getMainLooper())
private var nsd: NsdManager? = null
private var discovery: NsdManager.DiscoveryListener? = null
private var pairingHost: String? = null
private var pairingPort: Int? = null
@Volatile private var working = false
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_CODE -> {
val code = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_CODE)
?.filter(Char::isDigit)?.toString().orEmpty()
onCode(code)
}
ACTION_CANCEL -> finish(Stage.Idle)
else -> begin()
}
return START_NOT_STICKY
}
private fun begin() {
pairingHost = null
pairingPort = null
working = false
publish(Stage.Waiting)
startForeground(NOTIFICATION_ID, notification(Stage.Waiting))
startDiscovery()
main.removeCallbacks(timeout)
main.postDelayed(timeout, TIMEOUT_MS)
}
private fun startDiscovery() {
stopDiscovery()
val manager = getSystemService(Context.NSD_SERVICE) as NsdManager
val listener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) = Unit
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (serviceInfo.serviceType?.contains(PAIRING_TYPE) != true) return
runCatching { manager.resolveService(serviceInfo, resolveListener()) }
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
override fun onDiscoveryStopped(serviceType: String) = Unit
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
}
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
.onSuccess { nsd = manager; discovery = listener }
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
}
private fun resolveListener() = object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
val address = serviceInfo.host ?: return
if (!WirelessAdbFix.isLocalAddress(address)) return
main.post {
if (working || current.value == Stage.Done) return@post
pairingHost = address.hostAddress
pairingPort = serviceInfo.port
if (current.value !is Stage.CodeNeeded) show(Stage.CodeNeeded())
}
}
}
private fun onCode(code: String) {
val host = pairingHost
val port = pairingPort
if (working) return
if (host == null || port == null) {
show(Stage.Waiting)
return
}
if (code.length != 6) {
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
return
}
working = true
show(Stage.Working("Pairing…"))
Thread({
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
if (paired.isFailure) {
main.post {
working = false
pairingPort = null
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
}
return@Thread
}
main.post {
stopDiscovery()
show(Stage.Working("Paired. Connecting…"))
}
val result = runCatching {
val connectPort = WirelessAdbFix.localConnectPort(this)
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
main.post { show(Stage.Working("Applying the setting…")) }
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
}
main.post {
working = false
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
}
}, "wireless-adb-pairing").start()
}
private fun show(stage: Stage) {
publish(stage)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification(stage))
}
private fun finish(stage: Stage) {
main.removeCallbacks(timeout)
stopDiscovery()
publish(stage)
val manager = getSystemService(NotificationManager::class.java)
if (stage is Stage.Done || stage is Stage.Failed) {
manager?.notify(NOTIFICATION_ID, notification(stage))
stopForeground(STOP_FOREGROUND_DETACH)
} else {
stopForeground(STOP_FOREGROUND_REMOVE)
manager?.cancel(NOTIFICATION_ID)
}
stopSelf()
}
private fun stopDiscovery() {
val listener = discovery ?: return
runCatching { nsd?.stopServiceDiscovery(listener) }
discovery = null
}
override fun onDestroy() {
main.removeCallbacks(timeout)
stopDiscovery()
if (current.value.let { it is Stage.Waiting || it is Stage.CodeNeeded || it is Stage.Working }) publish(Stage.Idle)
super.onDestroy()
}
private fun notification(stage: Stage): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
NotificationManager.IMPORTANCE_HIGH).apply {
description = "Asks for the Wireless debugging pairing code while Settings is open"
setShowBadge(false)
setSound(null, null)
enableVibration(false)
})
val open = PendingIntent.getActivity(this, 0,
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE)
val cancel = PendingIntent.getService(this, 1,
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_IMMUTABLE)
val builder = Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentIntent(open)
.setShowWhen(false)
.setCategory(Notification.CATEGORY_STATUS)
when (stage) {
Stage.Waiting, Stage.Idle -> builder
.setContentTitle("Pair Wireless debugging")
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
.setStyle(Notification.BigTextStyle().bigText(
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
.setOngoing(true)
.setOnlyAlertOnce(true)
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
is Stage.CodeNeeded -> {
val reply = PendingIntent.getService(this, 2,
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
else PendingIntent.FLAG_UPDATE_CURRENT)
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
builder
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
.setContentText(text)
.setStyle(Notification.BigTextStyle().bigText(text))
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
}
is Stage.Working -> builder
.setContentTitle("Setting up Steam")
.setContentText(stage.step)
.setProgress(0, 0, true)
.setOngoing(true)
.setOnlyAlertOnce(true)
Stage.Done -> builder
.setContentTitle("Steam is ready")
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
.setAutoCancel(true)
is Stage.Failed -> builder
.setContentTitle("Wireless debugging setup stopped")
.setContentText(stage.error)
.setStyle(Notification.BigTextStyle().bigText(stage.error))
.setAutoCancel(true)
}
return builder.build()
}
companion object {
private const val CHANNEL_ID = "wireless-adb-pairing"
private const val NOTIFICATION_ID = 3
private const val PAIRING_TYPE = "_adb-tls-pairing._tcp"
private const val ACTION_CODE = "com.droiddeck.launcher.action.WIRELESS_ADB_CODE"
private const val ACTION_CANCEL = "com.droiddeck.launcher.action.WIRELESS_ADB_CANCEL"
private const val KEY_CODE = "code"
private const val TIMEOUT_MS = 5 * 60_000L
private val current = MutableStateFlow<Stage>(Stage.Idle)
val stage: StateFlow<Stage> = current
private fun publish(stage: Stage) { current.value = stage }
fun start(context: Context) {
context.getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
publish(Stage.Waiting)
val intent = Intent(context, WirelessAdbPairingService::class.java)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
fun cancel(context: Context) {
if (current.value.let { it is Stage.Done || it is Stage.Failed || it is Stage.Idle }) {
publish(Stage.Idle)
return
}
context.startService(Intent(context, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL))
}
fun notificationsEnabled(context: Context): Boolean =
context.getSystemService(NotificationManager::class.java)?.areNotificationsEnabled() != false
}
}
@@ -26,9 +26,24 @@ public final class PadBridge {
/** Slot 0 is the one the session exports; extra slots would each show as another pad. */
private static final int SLOT = 0;
private static final float DEAD_ZONE = 0.12f;
private static final long QAM_GUIDE_LEAD_MS = 80;
private static final long QAM_A_HOLD_MS = 120;
private static final long QAM_GUIDE_TAIL_MS = 40;
// The client takes A as part of the chord only once it has had Guide held for a while, and a
// client starved of CPU needs longer. Too short, and it acts on A as well, selecting whatever it
// had focused before opening QAM: with 80 ms of lead that was 3 times in 20 at rest, and 250 ms
// none in 20; with the session down to 1 fps a fixed 400 ms still let 3 in 15 through, where
// 1000 ms let none. The lead keeps 80 ms when frames are quick, for a QAM that feels immediate,
// and stretches to eight frames when they are slow.
private static final long QAM_GUIDE_LEAD_MIN_MS = 80;
private static final long QAM_GUIDE_LEAD_MAX_MS = 1500;
private static final int QAM_GUIDE_LEAD_FRAMES = 8;
private static final long QAM_A_HOLD_MS = 200;
private static final long QAM_GUIDE_TAIL_MS = 200;
// Off the main thread, so a busy UI cannot shorten or stretch the steps.
private static final Handler chordHandler;
static {
android.os.HandlerThread thread = new android.os.HandlerThread("qam-chord", android.os.Process.THREAD_PRIORITY_DISPLAY);
thread.start();
chordHandler = new Handler(thread.getLooper());
}
private final FakeInputWriter writer;
private final PadState state = new PadState();
@@ -212,21 +227,27 @@ public final class PadBridge {
qamChordActive = true;
int generation = ++qamChordGeneration;
publish();
mainHandler.postDelayed(() -> pressQamA(generation), QAM_GUIDE_LEAD_MS);
chordHandler.postDelayed(() -> pressQamA(generation), qamGuideLeadMs());
}
static long qamGuideLeadMs() {
long frame = com.droiddeck.launcher.wayland.WaylandCompositor.recentFrameIntervalMs();
if (frame <= 0) return QAM_GUIDE_LEAD_MIN_MS;
return Math.max(QAM_GUIDE_LEAD_MIN_MS, Math.min(QAM_GUIDE_LEAD_MAX_MS, frame * QAM_GUIDE_LEAD_FRAMES));
}
private synchronized void pressQamA(int generation) {
if (generation != qamChordGeneration || !qamChordActive) return;
qamSyntheticAPressed = true;
publish();
mainHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS);
chordHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS);
}
private synchronized void releaseQamA(int generation) {
if (generation != qamChordGeneration || !qamChordActive) return;
qamSyntheticAPressed = false;
publish();
mainHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS);
chordHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS);
}
private synchronized void releaseQamGuide(int generation) {
@@ -736,6 +736,24 @@ class SessionService : Service() {
return try { Pair(fields[1].toInt(), fields[19].toLong()) } catch (e: NumberFormatException) { null }
}
private fun askSteamToExit(prootPid: Int) {
if (!File("/proc/$prootPid").exists()) return
val request = File(LinuxRuntime.sessionRoot(this), "steam-stop")
try {
request.writeText("")
} catch (e: Exception) {
Log.w(TAG, "could not ask the Steam client to exit", e)
return
}
val started = System.currentTimeMillis()
if (waitForExit(prootPid, STEAM_PICKUP_MS) || request.exists()) {
request.delete()
return
}
val exited = waitForExit(prootPid, STEAM_EXIT_MS)
Log.i(TAG, "Steam client ${if (exited) "exited" else "did not exit"} after ${System.currentTimeMillis() - started} ms")
}
private fun waitForExit(pid: Int, timeoutMs: Long): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
@@ -953,9 +971,11 @@ class SessionService : Service() {
}
components.clear()
FakeInputWriter.releaseAllRingSlots()
val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
val finishAfterTeardown: () -> Unit = {
if (prootPid > 1 || auxiliary.isNotEmpty()) {
Thread({
if (prootPid > 1 && steamClientMayRun) askSteamToExit(prootPid)
auxiliary.forEach { (pid, started) -> teardown(pid, started) }
if (prootPid > 1) teardown(prootPid)
killGuestLeftovers()
@@ -1114,6 +1134,8 @@ class SessionService : Service() {
"steamwebhelper", "linuxfs/opt/android-host/proot", "/libproot.so", "pulseaudio/libpulseaudio.so")
/** How long proot gets to run its own cleanup before it is killed outright. */
private const val GRACE_MS = 1200L
private const val STEAM_PICKUP_MS = 1500L
private const val STEAM_EXIT_MS = 10_000L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log"
@@ -157,6 +157,8 @@ class FrontEndActions(
val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless debugging is unavailable") },
val onCopyPhantomCommand: (Boolean) -> Unit = {},
val onDismissPhantomGate: () -> Unit = {},
val onStartWirelessAdbPairing: () -> Unit = {},
val onOpenNotificationSettings: () -> Unit = {},
val controller: ControllerActions? = null,
)
@@ -469,7 +471,13 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
status = s.phantomProcessStatus,
onDismiss = a.onDismissPhantomGate,
onOpenDeveloperOptions = requestDeveloperOptions,
onSetUpWirelessAdb = { requestWirelessAdbFix(false) },
onFixWithWirelessDebugging = {
a.onStartWirelessAdbPairing()
requestDeveloperOptions()
},
onEnterAddressManually = { requestWirelessAdbFix(false) },
onCopyCommand = { a.onCopyPhantomCommand(false) },
onOpenNotificationSettings = a.onOpenNotificationSettings,
)
}
}
@@ -36,9 +36,18 @@ import kotlinx.coroutines.coroutineScope
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.runtime.snapshotFlow
import androidx.compose.animation.core.spring
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.unit.Dp
import java.lang.ref.WeakReference
import kotlin.math.PI
import kotlin.math.sin
// Starting a session from a blue button: the button grows until the page is its blue, and the
// loading screen opens on that blue and gathers it into the ball of the throbber.
// Stopping one from the drawer runs it the other way: Stop grows until the page is blue, and the
// front end opens on that blue and draws it back down into the button the session came from.
/** Carries the flood's colour to the session, which opens on it. */
const val EXTRA_FLOOD = "com.droiddeck.launcher.FLOOD"
@@ -48,6 +57,8 @@ internal object LaunchOrigin {
private var bounds: Rect? = null
private var button: Any? = null
private var at = 0L
/** The last button a session was started from, for a stop to return into. */
private var last: WeakReference<Any>? = null
/** The button a flood is growing out of: it steps aside so only the flood shows. */
var flooding by mutableStateOf<Any?>(null)
@@ -55,9 +66,22 @@ internal object LaunchOrigin {
fun mark(r: Rect, key: Any) {
bounds = r
button = key
last = WeakReference(key)
at = SystemClock.uptimeMillis()
}
/**
* Where the button the running session was started from sits now, if it is still on screen;
* it hides for the return, as for the flood.
*/
fun takeReturn(): Rect? {
val key = last?.get() as? Array<*> ?: return null
val c = key.getOrNull(0) as? LayoutCoordinates ?: return null
if (!c.isAttached) return null
flooding = key
return c.boundsInRoot()
}
/** The button behind a launch starting now, if one was pressed just before; it hides for the flood. */
fun take(): Rect? {
val b = bounds?.takeIf { SystemClock.uptimeMillis() - at < 1_500 }
@@ -68,6 +92,23 @@ internal object LaunchOrigin {
}
}
/** A session stopped behind a flood: the blue the front end opens on, for a few seconds. */
internal object QuitFlood {
private var color: Int? = null
private var at = 0L
fun mark(argb: Int) {
color = argb
at = SystemClock.uptimeMillis()
}
fun take(): Int? {
val c = color?.takeIf { SystemClock.uptimeMillis() - at < 3_000 }
color = null
return c
}
}
/** The page a flood grows over, sinking back a touch as it is covered; [progress] is the flood's. */
@Composable
internal fun FloodBehind(progress: () -> Float, content: @Composable () -> Unit) {
@@ -87,9 +128,17 @@ private val Gather = CubicBezierEasing(0.6f, 0f, 0.15f, 1f)
* Swallows touches while it runs.
*/
@Composable
internal fun LaunchFlood(from: Rect, onProgress: (Float) -> Unit, onCovered: () -> Unit) {
internal fun LaunchFlood(
from: Rect,
onProgress: (Float) -> Unit,
/** The button's own colours, where it is not the page's blue one (a red Stop). */
fromColors: Pair<Color, Color>? = null,
cornerAtRest: Dp = 12.dp,
onCovered: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val start = fromColors ?: (colors.primary to pal.primary2)
// Left, top, right, bottom: 0 on the button, 1 on the page's edge. Springs overshoot past the
// edge, off the page, so the wobble is felt in the pull and never seen as a shrink.
val edges = remember { List(4) { Animatable(0f) } }
@@ -117,30 +166,83 @@ internal fun LaunchFlood(from: Rect, onProgress: (Float) -> Unit, onCovered: ()
}
}
Canvas(Modifier.fillMaxSize().onSizeChanged { page = Size(it.width.toFloat(), it.height.toFloat()) }.pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
val (l, t, r, b) = edges.map { it.value }
val mean = edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
// The button sinks with the page behind (FloodBehind), so the flood leaves from where it is now.
val k = sinkScale(mean)
val cx = size.width / 2f
val cy = size.height / 2f
val left = lerp(cx + (from.left - cx) * k, 0f, l)
val top = lerp(cy + (from.top - cy) * k, 0f, t)
val right = lerp(cx + (from.right - cx) * k, size.width, r)
val bottom = lerp(cy + (from.bottom - cy) * k, size.height, b)
val w = (right - left).coerceAtLeast(0f)
val h = (bottom - top).coerceAtLeast(0f)
// A button's corners at rest, a blob in flight, square once it fills the page.
val blob = kotlin.math.sin(Math.PI * mean).toFloat().coerceAtLeast(0f)
val corner = lerp(12.dp.toPx() * (1f - mean), minOf(w, h) * 0.42f, blob)
// The button's gradient gives way to the ball's flat blue in the first half.
val mix = (mean * 2f).coerceAtMost(1f)
drawRoundRect(
Brush.linearGradient(
listOf(lerp(colors.primary, pal.signal, mix), lerp(pal.primary2, pal.signal, mix)),
start = Offset(left, top), end = Offset(right, bottom),
),
topLeft = Offset(left, top), size = Size(w, h), cornerRadius = CornerRadius(corner),
)
drawFlood(from, edges.map { it.value }, start, pal.signal, cornerAtRest.toPx())
}
}
/**
* The flood from a button at [from] with its edges [v] of the way to the page's (left, top, right,
* bottom): the button sinking with the page behind, blobby in flight, its colours [start] giving
* way to the flat [signal] in the first half.
*/
private fun DrawScope.drawFlood(from: Rect, v: List<Float>, start: Pair<Color, Color>, signal: Color, cornerAtRest: Float) {
val (l, t, r, b) = v
val mean = v.sumOf { it.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
// The button sinks with the page behind (FloodBehind), so the flood leaves from where it is now.
val k = sinkScale(mean)
val cx = size.width / 2f
val cy = size.height / 2f
val left = lerp(cx + (from.left - cx) * k, 0f, l)
val top = lerp(cy + (from.top - cy) * k, 0f, t)
val right = lerp(cx + (from.right - cx) * k, size.width, r)
val bottom = lerp(cy + (from.bottom - cy) * k, size.height, b)
val w = (right - left).coerceAtLeast(0f)
val h = (bottom - top).coerceAtLeast(0f)
// A button's corners at rest, a blob in flight, square once it fills the page.
val blob = sin(PI * mean).toFloat().coerceAtLeast(0f)
val corner = lerp(cornerAtRest * (1f - mean), minOf(w, h) * 0.42f, blob)
// The button's gradient gives way to the ball's flat blue in the first half.
val mix = (mean * 2f).coerceAtMost(1f)
val brush = Brush.linearGradient(
listOf(lerp(start.first, signal, mix), lerp(start.second, signal, mix)),
start = Offset(left, top), end = Offset(right, bottom),
)
drawRoundRect(brush, topLeft = Offset(left, top), size = Size(w, h), cornerRadius = CornerRadius(corner))
}
/**
* The front end's first frames after a stop: the page in [flood] after a beat, drawn back down
* into the button at [to] (root px, measured before the page sank) on critically damped springs,
* the far edges first, so it sets down on the button without a bounce. [onProgress] (1 to 0) lets
* the page behind rise back. No button to return to: the blue fades. Calls [onLanded] at the end.
*/
@Composable
internal fun FloodReturn(flood: Color, to: Rect?, onProgress: (Float) -> Unit, onLanded: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val edges = remember { List(4) { Animatable(1f) } }
val fade = remember { Animatable(1f) }
val landed by rememberUpdatedState(onLanded)
val progress by rememberUpdatedState(onProgress)
var page by remember { mutableStateOf(Size.Zero) }
LaunchedEffect(page != Size.Zero) {
if (page == Size.Zero) return@LaunchedEffect
progress(1f)
// A beat of plain blue first, so the change of screen does not read as a cut.
delay(Motion.ms(220).toLong())
if (to == null || Motion.scale == 0f) {
fade.animateTo(0f, tween(Motion.ms(240).coerceAtLeast(1)))
} else {
val travel = listOf(to.left, to.top, page.width - to.right, page.height - to.bottom).map { it.coerceAtLeast(0f) }
val far = travel.max().coerceAtLeast(1f)
coroutineScope {
edges.mapIndexed { i, edge ->
val lead = travel[i] / far
launch {
delay(Motion.ms((180 * (1f - lead)).toInt()).toLong())
edge.animateTo(0f, spring(dampingRatio = 1f, stiffness = lerp(110f, 190f, lead))) {
progress(edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f)
}
}
}.forEach { it.join() }
}
}
progress(0f)
landed()
}
Canvas(Modifier.fillMaxSize().onSizeChanged { page = Size(it.width.toFloat(), it.height.toFloat()) }.pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
if (to == null) drawRect(flood, alpha = fade.value)
else drawFlood(to, edges.map { it.value }, colors.primary to pal.primary2, flood, 12.dp.toPx())
}
}
@@ -1,64 +1,96 @@
package com.droiddeck.launcher.ui
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.core.WirelessAdbPairingService.Stage
@Composable
fun PhantomProcessGatePage(
status: PhantomProcessStatus,
onDismiss: () -> Unit,
onOpenDeveloperOptions: () -> Unit,
onSetUpWirelessAdb: () -> Unit,
onFixWithWirelessDebugging: () -> Unit,
onEnterAddressManually: () -> Unit,
onCopyCommand: () -> Unit,
onOpenNotificationSettings: () -> Unit,
) {
BackHandler(onBack = onDismiss)
val context = LocalContext.current
val stage by WirelessAdbPairingService.stage.collectAsState()
val environment by produceState(GateEnvironment.read(context)) {
while (true) {
value = GateEnvironment.read(context)
kotlinx.coroutines.delay(2_000)
}
}
val hasToggle = PhantomProcessLimit.hasDeveloperToggle()
BoxWithConstraints(Modifier.fillMaxWidth()) {
val compact = maxWidth < 620.dp && maxHeight < 500.dp
SettingsPage(
host = rememberMenuHost(),
title = if (compact) "Steam paused" else "Steam cannot start yet",
title = "One Android setting to change",
eyebrow = "Steam",
lede = if (compact) compactStatus(status) else "${PhantomProcessLimit.title(status)} · checking again every two seconds",
lede = if (compact) null else "Android stops apps that start lots of background processes, and Steam starts dozens. " +
"Until this is changed, games close with no error.",
onBack = onDismiss,
// Scrolls, so the larger text never pushes the ADB command off a short screen.
scrollContent = true,
compactLayout = compact,
) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val wide = maxWidth >= 620.dp
Column(
modifier = Modifier.widthIn(max = 900.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
when {
compact -> CompactGateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
wide -> Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.weight(1f)) {
GateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
}
Column(Modifier.weight(1f)) { AdbFallback() }
}
else -> {
GateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
AdbFallback()
}
val fix: @Composable ColumnScope.() -> Unit = {
FixGroup(hasToggle, stage, environment, compact, onOpenDeveloperOptions, onFixWithWirelessDebugging,
onOpenNotificationSettings, onCancel = { WirelessAdbPairingService.cancel(context) })
}
val side: @Composable ColumnScope.() -> Unit = {
StatusGroup(status, compact)
OtherWays(hasToggle, compact, onOpenDeveloperOptions, onFixWithWirelessDebugging, onEnterAddressManually, onCopyCommand)
}
if (wide) {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.widthIn(max = 900.dp).fillMaxWidth()) {
Column(Modifier.weight(1.2f), content = fix)
Column(Modifier.weight(1f), content = side)
}
} else {
Column(Modifier.widthIn(max = 600.dp).fillMaxWidth()) {
fix()
side()
}
}
}
@@ -66,97 +98,168 @@ fun PhantomProcessGatePage(
}
}
private fun compactStatus(status: PhantomProcessStatus): String {
val state = when (status) {
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
PhantomProcessStatus.UNREADABLE -> "Child-process limit is unreadable"
else -> PhantomProcessLimit.title(status)
private data class GateEnvironment(val onWifi: Boolean, val notifications: Boolean) {
companion object {
fun read(context: Context): GateEnvironment {
val connectivity = context.getSystemService(ConnectivityManager::class.java)
val wifi = runCatching {
connectivity?.getNetworkCapabilities(connectivity.activeNetwork)
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
}.getOrDefault(true)
return GateEnvironment(wifi, WirelessAdbPairingService.notificationsEnabled(context))
}
}
return "$state · retrying every 2 sec"
}
@Composable
private fun CompactGateInstructions(
status: PhantomProcessStatus,
private fun FixGroup(
hasToggle: Boolean,
stage: Stage,
environment: GateEnvironment,
compact: Boolean,
onOpenDeveloperOptions: () -> Unit,
onSetUpWirelessAdb: () -> Unit,
onFixWithWirelessDebugging: () -> Unit,
onOpenNotificationSettings: () -> Unit,
onCancel: () -> Unit,
) {
SettingsGroup("1 · Developer options", compact = true) {
val pairing = stage is Stage.Waiting || stage is Stage.CodeNeeded || stage is Stage.Working
SettingsGroup(if (hasToggle) "Developer options" else "Fix it for me", compact = compact) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 7.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
PhantomProcessLimit.gateInstructions(status),
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
PrimaryButton("Open Developer options", compact = true, onClick = onOpenDeveloperOptions)
SecondaryButton("Use Wireless debugging", compact = true, onClick = onSetUpWirelessAdb)
when {
pairing -> PairingProgress(stage, onCancel)
hasToggle -> {
Body("Turn on “Disable child process restrictions”, then come back. Keep Developer options on afterwards: turning them off turns the switch back off.")
PrimaryButton("Open Developer options", compact = compact, onClick = onOpenDeveloperOptions)
}
else -> {
Body("This Android version has no switch for it, so DroidDeck changes it over Wireless debugging on this device. About a minute, no computer needed.")
(stage as? Stage.Failed)?.let { Body(it.error, error = true) }
PrimaryButton(
if (stage is Stage.Failed) "Try again" else "Fix it for me",
compact = compact,
enabled = environment.onWifi && environment.notifications,
onClick = onFixWithWirelessDebugging,
)
}
}
if (!hasToggle && !pairing) {
if (!environment.onWifi) {
Body("Wireless debugging needs Wi-Fi. Connect to any network first; it doesn't need internet.", warn = true)
}
if (!environment.notifications) {
Body("The pairing code is entered in a notification. Allow notifications for DroidDeck, or use Other ways.", warn = true)
SecondaryButton("Allow notifications", compact = compact, onClick = onOpenNotificationSettings)
}
}
}
}
SettingsGroup("3 · Computer ADB last resort", compact = true) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
"If the setting is missing and Wireless debugging is unavailable, run this from a computer:",
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
PhantomProcessLimit.ADB_COMMAND,
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun PairingProgress(stage: Stage, onCancel: () -> Unit) {
val steps = listOf(
"Open Settings → Developer options → Wireless debugging and turn it on",
"Tap “Pair device with pairing code”",
"Type the code into the DroidDeck notification",
)
val active = when (stage) {
Stage.Waiting -> 1
is Stage.CodeNeeded -> 2
else -> 3
}
steps.forEachIndexed { index, step ->
val done = index < active
val current = index == active
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (done) "✓" else "${index + 1}", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = if (done) LocalPalette.current.good else MaterialTheme.colorScheme.onSurfaceVariant)
Text(step, style = MaterialTheme.typography.bodySmall,
color = if (current) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(
when (stage) {
Stage.Waiting -> "Waiting for the pairing pop-up…"
is Stage.CodeNeeded -> stage.error ?: "Pop-up found. Enter its code in the notification."
is Stage.Working -> stage.step
else -> ""
},
style = MaterialTheme.typography.bodySmall,
color = if (stage is Stage.CodeNeeded && stage.error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
SecondaryButton("Cancel", compact = true, onClick = onCancel)
}
}
@Composable
private fun StatusGroup(status: PhantomProcessStatus, compact: Boolean) {
SettingsGroup("Status", compact = compact) {
Column(Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(PhantomProcessLimit.title(status), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground)
Body("Checking every 2 seconds. This page closes when it's off.")
}
}
}
@Composable
private fun GateInstructions(
status: PhantomProcessStatus,
private fun OtherWays(
hasToggle: Boolean,
compact: Boolean,
onOpenDeveloperOptions: () -> Unit,
onSetUpWirelessAdb: () -> Unit,
onFixWithWirelessDebugging: () -> Unit,
onEnterAddressManually: () -> Unit,
onCopyCommand: () -> Unit,
) {
SettingsGroup("1 · Developer options first") {
Column(
modifier = Modifier.fillMaxWidth().padding(14.dp),
var open by rememberSaveable { mutableStateOf(false) }
SettingsGroup("Other ways", compact = compact) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { open = !open }.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Body(if (hasToggle) "Wireless debugging, manual address, computer ADB" else "Manual address, computer ADB, Developer options", modifier = Modifier.weight(1f))
Text(if (open) "▴" else "▾", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (open) Column(
modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (hasToggle) {
SecondaryButton("Use Wireless debugging instead", compact = true, onClick = onFixWithWirelessDebugging)
} else {
Body("Some ROMs add the switch anyway. Look for “child process” in Developer options.")
SecondaryButton("Open Developer options", compact = true, onClick = onOpenDeveloperOptions)
}
Body("If the notification doesn't work, enter the pop-up's address and code yourself.")
SecondaryButton("Enter address manually", compact = true, onClick = onEnterAddressManually)
Body("From a computer with ADB:")
Text(
PhantomProcessLimit.gateInstructions(status),
PhantomProcessLimit.adbCommand(),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
PrimaryButton("Open Developer options", onClick = onOpenDeveloperOptions)
SecondaryButton("Use Wireless debugging", onClick = onSetUpWirelessAdb)
SecondaryButton("Copy command", compact = true, onClick = onCopyCommand)
}
}
}
@Composable
private fun AdbFallback() {
SettingsGroup("3 · Computer ADB last resort") {
Column(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Use this only if Developer options has no setting and Wireless debugging is unavailable. Connect this device to a computer with ADB and run:",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
Text(
PhantomProcessLimit.ADB_COMMAND,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun Body(text: String, modifier: Modifier = Modifier, error: Boolean = false, warn: Boolean = false) {
Text(
text,
modifier = modifier,
style = MaterialTheme.typography.bodySmall,
color = when {
error -> MaterialTheme.colorScheme.error
warn -> androidx.compose.ui.graphics.Color(0xFFFFB86B)
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
@Composable
@@ -85,6 +85,9 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@@ -172,6 +175,8 @@ class DrawerActions(
val onBackground: () -> Unit,
val onShareLogs: () -> Unit,
val onStop: () -> Unit,
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
val onClose: () -> Unit,
/** Components tab: every Proton with what it uses; null until first read. */
val components: ComponentsManager.Snapshot? = null,
@@ -453,7 +458,14 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
if (confirmStop) {
val cancelFocus = remember { FocusRequester() }
val cancel = { confirmStop = false }
val stop = { confirmStop = false; a.onStop() }
// Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken
// against the screen and the session's overlay converts them back.
var stopOnScreen by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
val stop = {
confirmStop = false
val from = a.onStopFrom
if (from != null) from(stopOnScreen) else a.onStop()
}
LaunchedEffect(controllerActive) {
if (controllerActive) {
androidx.compose.runtime.withFrameNanos { }
@@ -465,9 +477,13 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
modifier = Modifier.controllerBack(onBack = cancel),
title = { Text("Stop session?") },
confirmButton = {
val dialogView = LocalView.current
TextButton(
onClick = stop,
modifier = Modifier.controllerConfirm(onClick = stop),
modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c ->
val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) }
stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat())
},
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
) { Text("Stop") }
},
@@ -67,9 +67,30 @@ public final class WaylandCompositor {
if (l != null) l.onGameSurface(window, gpuName);
}
private static volatile long lastFrameNanos;
private static volatile long frameIntervalNanos;
/**
* How long frames are taking to arrive right now, in ms, or -1 before the first: the smoothed
* interval, or the time since the last frame when that is longer (a session that has stalled).
*/
public static long recentFrameIntervalMs() {
long last = lastFrameNanos;
if (last == 0) return -1;
return Math.max(frameIntervalNanos, System.nanoTime() - last) / 1_000_000;
}
/** Invoked from native (banner_on_game_frame) for every frame of the HUD's window. */
@SuppressWarnings("unused")
static void onGameFrame() {
long now = System.nanoTime();
long previous = lastFrameNanos;
lastFrameNanos = now;
if (previous != 0) {
long interval = now - previous;
long smoothed = frameIntervalNanos;
frameIntervalNanos = smoothed == 0 ? interval : (smoothed * 3 + interval) / 4;
}
com.droiddeck.launcher.session.PerfHints.onFrame();
GameListener l = gameListener;
if (l != null) l.onGameFrame();
@@ -1,11 +1,8 @@
<!--
Status-bar icon: Android renders it as a single-colour silhouette, so this is the launcher
controller reduced to one path, with the d-pad and the buttons punched out (evenOdd) rather
than drawn in a second colour that would never survive.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd"
android:pathData="M40,36 H68 C79,36 87,44.5 87,55.5 v4 c0,6.6 -5.4,12 -12,12 c-4.5,0 -8.6,-2.5 -10.6,-6.5 L62,60 H46 l-2.4,5 C41.6,69 37.5,71.5 33,71.5 c-6.6,0 -12,-5.4 -12,-12 v-4 C21,44.5 29,36 40,36 Z M33,47.5 h4.5 v-4.5 h4.5 v4.5 h4.5 v4.5 h-4.5 v4.5 h-4.5 v-4.5 h-4.5 z M71,43 a2.5,2.5 0 1,0 0.01,0 z M77,47.5 a2.5,2.5 0 1,0 0.01,0 z M65,47.5 a2.5,2.5 0 1,0 0.01,0 z M71,52 a2.5,2.5 0 1,0 0.01,0 z M54,44 a6,6 0 1,0 0.01,0 z" />
android:viewportWidth="239.92" android:viewportHeight="239.92">
<group android:translateX="55.98" android:translateY="8.1">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd"
android:pathData="M-31.98,0 H63.97 C125.81,0 175.93,50.08 175.93,111.86 S125.81,223.72 63.97,223.72 H-31.98 Z M-7.83,111.86 a71.81,71.81 0 1,0 143.62,0 a71.81,71.81 0 1,0 -143.62,0 Z M8,111.86 a55.98,55.98 0 1,0 111.96,0 a55.98,55.98 0 1,0 -111.96,0 Z M56.055,0 h15.83 v40.49 h-15.83 Z M56.055,183.23 h15.83 v40.49 h-15.83 Z" />
</group>
</vector>
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PhantomProcessLimitTest {
@@ -26,4 +28,33 @@ class PhantomProcessLimitTest {
assertEquals(PhantomProcessStatus.UNSET, PhantomProcessLimit.status(null, null))
assertEquals(PhantomProcessStatus.UNSET, PhantomProcessLimit.status("", ""))
}
@Test
fun android12UsesDeviceConfig() {
assertEquals(
listOf(
"device_config set_sync_disabled_for_tests persistent",
"device_config put activity_manager max_phantom_processes 2147483647",
),
PhantomProcessLimit.shellCommands(enabled = false, sdk = 31),
)
assertTrue(PhantomProcessLimit.verified("2147483647\n", enabled = false, sdk = 31))
assertFalse(PhantomProcessLimit.verified("null", enabled = false, sdk = 31))
}
@Test
fun android12LAndLaterUseTheFlag() {
assertEquals(
listOf("settings put global settings_enable_monitor_phantom_procs false"),
PhantomProcessLimit.shellCommands(enabled = false, sdk = 33),
)
assertTrue(PhantomProcessLimit.verified("false", enabled = false, sdk = 34))
assertFalse(PhantomProcessLimit.verified("null", enabled = false, sdk = 33))
}
@Test
fun developerToggleStartsAtAndroid14() {
assertFalse(PhantomProcessLimit.hasDeveloperToggle(33))
assertTrue(PhantomProcessLimit.hasDeveloperToggle(34))
}
}
@@ -547,6 +547,23 @@ BUSCONF
# An update that cannot install asks for a restart (rc=42) every time; the count is capped so a
# session can never again loop in silence, which reads as a menu opening and closing by itself.
steam_updates=0
rm -f "$HOME/.bl-stop-requested"
if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -d "$BL_LAUNCH_DIR" ]; then
rm -f "$BL_LAUNCH_DIR/steam-stop"
(
while :; do
if [ -e "$BL_LAUNCH_DIR/steam-stop" ]; then
rm -f "$BL_LAUNCH_DIR/steam-stop"
: > "$HOME/.bl-stop-requested"
echo "== steam: the app asked for a clean exit"
"$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
exit 0
fi
sleep 0.5
done
) &
stop_watcher=$!
fi
while :; do
rm -f "$HOME/.bl-restart-once"
if [ -f "$steam_beta" ] && [ "$(cat "$steam_beta" 2>/dev/null)" != "$steam_channel" ]; then
@@ -563,6 +580,10 @@ BUSCONF
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
if [ -f "$HOME/.bl-stop-requested" ]; then
rm -f "$HOME/.bl-stop-requested" "$HOME/.bl-restart-once"
break
fi
# The session asked for this exit (the ARM64 Proton landed): start again, without the URL
# arguments, which were acted on. Not counted against the update cap.
if [ -f "$HOME/.bl-restart-once" ]; then
@@ -579,6 +600,10 @@ BUSCONF
# The URL arguments were acted on the first time; a restart must not repeat them.
set --
done
if [ -n "${stop_watcher:-}" ]; then
kill "$stop_watcher" 2>/dev/null || true
wait "$stop_watcher" 2>/dev/null || true
fi
if [ -n "${games_watcher:-}" ]; then
kill "$games_watcher" 2>/dev/null || true
wait "$games_watcher" 2>/dev/null || true
+4 -1
View File
@@ -131,6 +131,9 @@ static struct pad *pad_for_syslink(const char *path) {
static void *next_symbol(const char *name) { return dlsym(RTLD_NEXT, name); }
char *bl_cached_realpath(const char *path, char *resolved, char *(*real)(const char *, char *))
__attribute__((visibility("hidden")));
/* Wine reaches libudev from its HID bus driver, which is the only caller these pads are for. */
static int caller_is_winebus(void *caller) {
Dl_info info;
@@ -152,7 +155,7 @@ char *realpath(const char *path, char *resolved) {
}
if (real_realpath == NULL) real_realpath = next_symbol("realpath");
if (real_realpath == NULL) return NULL;
return real_realpath(path, resolved);
return bl_cached_realpath(path, resolved, real_realpath);
}
void *udev_device_new_from_syspath(void *udev, const char *syspath) {
+189
View File
@@ -0,0 +1,189 @@
/*
* Path lookups the Steam client repeats, answered from a short-lived cache.
*
* While it registers its compatibility tools after logon, the client resolves the same few
* directories - its local tools under compatibilitytools.d and its own platform directory - with
* realpath() and access() thousands of times a second. Each realpath() is one readlinkat() per
* path component, and under proot every one of those is a trapped syscall with a round trip
* through the tracer. Measured on an AYN Thor, that loop was 23 of the client's 31 seconds of
* startup, with proot busy for 16 of them.
*
* Only the client itself uses the cache, never a game or a helper. Only successful answers are
* kept, and only for a moment: a path that stops existing is noticed within PATH_CACHE_TTL_NS, and
* one that starts existing is never hidden, since failures always go to the filesystem.
* BL_NO_PATH_CACHE=1 turns it off.
*/
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#define PATH_CACHE_SLOTS 256
#define PATH_CACHE_KEY_MAX 512
#define PATH_CACHE_TTL_NS 2000000000LL
enum { KIND_REALPATH, KIND_ACCESS };
struct entry {
long long stamp;
unsigned hash;
int kind;
int mode;
int flags;
char key[PATH_CACHE_KEY_MAX];
char *value;
};
static struct entry table[PATH_CACHE_SLOTS];
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static long long now_ns(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC_COARSE, &t);
return (long long)t.tv_sec * 1000000000LL + t.tv_nsec;
}
static int enabled(void) {
static int cached = -1;
const char *off;
if (cached >= 0) return cached;
off = getenv("BL_NO_PATH_CACHE");
cached = strcmp(program_invocation_short_name, "steam") == 0 &&
strstr(program_invocation_name, "steamrtarm64") != NULL &&
!(off != NULL && off[0] == '1');
return cached;
}
static unsigned hash_of(const char *s, int kind, int mode, int flags) {
unsigned h = 2166136261u ^ (unsigned)(kind * 31 + mode * 7 + flags);
while (*s) h = (h ^ (unsigned char)*s++) * 16777619u;
return h;
}
static int cacheable(const char *path) {
return path != NULL && path[0] == '/' && strlen(path) < PATH_CACHE_KEY_MAX && enabled();
}
static struct entry *find(const char *path, int kind, int mode, int flags, unsigned h) {
struct entry *e = &table[h % PATH_CACHE_SLOTS];
if (e->stamp == 0 || e->hash != h || e->kind != kind || e->mode != mode || e->flags != flags) return NULL;
if (now_ns() - e->stamp > PATH_CACHE_TTL_NS || strcmp(e->key, path) != 0) return NULL;
return e;
}
static void store(const char *path, int kind, int mode, int flags, unsigned h, const char *value) {
struct entry *e = &table[h % PATH_CACHE_SLOTS];
char *copy = value != NULL ? strdup(value) : NULL;
if (value != NULL && copy == NULL) return;
free(e->value);
e->value = copy;
e->hash = h;
e->kind = kind;
e->mode = mode;
e->flags = flags;
memcpy(e->key, path, strlen(path) + 1);
e->stamp = now_ns();
}
__attribute__((visibility("hidden")))
char *bl_cached_realpath(const char *path, char *resolved, char *(*real)(const char *, char *)) {
char out[PATH_MAX];
unsigned h;
struct entry *e;
if (!cacheable(path)) return real(path, resolved);
h = hash_of(path, KIND_REALPATH, 0, 0);
pthread_mutex_lock(&lock);
e = find(path, KIND_REALPATH, 0, 0, h);
if (e != NULL && e->value != NULL) {
size_t n = strlen(e->value) + 1;
char *result = resolved != NULL ? resolved : malloc(n);
if (result != NULL) memcpy(result, e->value, n);
pthread_mutex_unlock(&lock);
return result;
}
pthread_mutex_unlock(&lock);
if (real(path, out) == NULL) return NULL;
pthread_mutex_lock(&lock);
store(path, KIND_REALPATH, 0, 0, h, out);
pthread_mutex_unlock(&lock);
if (resolved != NULL) {
memcpy(resolved, out, strlen(out) + 1);
return resolved;
}
return strdup(out);
}
static int cached_access(const char *path, int mode, int flags, int (*check)(const char *, int, int)) {
unsigned h;
int hit;
int result;
if (!cacheable(path)) return check(path, mode, flags);
h = hash_of(path, KIND_ACCESS, mode, flags);
pthread_mutex_lock(&lock);
hit = find(path, KIND_ACCESS, mode, flags, h) != NULL;
pthread_mutex_unlock(&lock);
if (hit) return 0;
result = check(path, mode, flags);
if (result == 0) {
pthread_mutex_lock(&lock);
store(path, KIND_ACCESS, mode, flags, h, NULL);
pthread_mutex_unlock(&lock);
}
return result;
}
static int (*next_faccessat(void))(int, const char *, int, int) {
static int (*real)(int, const char *, int, int);
if (real == NULL) real = (int (*)(int, const char *, int, int))dlsym(RTLD_NEXT, "faccessat");
return real;
}
static int check_cwd(const char *path, int mode, int flags) {
int (*real)(int, const char *, int, int) = next_faccessat();
if (real == NULL) {
errno = ENOSYS;
return -1;
}
return real(AT_FDCWD, path, mode, flags);
}
int access(const char *path, int mode) {
return cached_access(path, mode, 0, check_cwd);
}
int euidaccess(const char *path, int mode) {
return cached_access(path, mode, AT_EACCESS, check_cwd);
}
int eaccess(const char *path, int mode) {
return cached_access(path, mode, AT_EACCESS, check_cwd);
}
int faccessat(int dirfd, const char *path, int mode, int flags) {
int (*real)(int, const char *, int, int);
if (dirfd == AT_FDCWD) {
return cached_access(path, mode, flags, check_cwd);
}
real = next_faccessat();
if (real == NULL) {
errno = ENOSYS;
return -1;
}
return real(dirfd, path, mode, flags);
}