mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
633ef0814a | ||
|
|
7c422577b6 | ||
|
|
1cc1ee994d |
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -431,6 +432,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() },
|
||||
@@ -1040,15 +1047,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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user