From 0fd50d76b283ecd0cffcb70bccfc57b3eb203215 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sun, 11 Oct 2026 02:07:23 -0400 Subject: [PATCH 1/8] feat: target Android API 36 --- app/build.gradle | 15 +---- app/src/main/AndroidManifest.xml | 26 ++++++-- .../com/droiddeck/launcher/MainActivity.kt | 11 +++- .../launcher/audio/PulseAudioComponent.java | 5 +- .../droiddeck/launcher/core/HostProcess.kt | 2 + .../droiddeck/launcher/core/WifiDiscovery.kt | 8 +-- .../launcher/files/FilePickerActivity.kt | 1 - .../launcher/input/SteamControllerBle.kt | 2 +- .../launcher/runtime/LinuxRuntime.java | 3 +- .../launcher/session/SessionPrefs.kt | 6 ++ tools/linuxfs/preload/noexec.c | 59 ++++++++++++++++++- 11 files changed, 103 insertions(+), 35 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 6752f032b..89f42cf4a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -80,7 +80,7 @@ def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines() android { namespace 'com.droiddeck.launcher' - compileSdk 34 + compileSdk 36 // The NDK is pinned by property so CI can hand in whatever the runner already has rather than // downloading a second copy; see gradle.properties. ndkVersion project.findProperty('ndkVersion') ?: '27.3.13750724' @@ -88,10 +88,7 @@ android { defaultConfig { applicationId "com.droiddeck.launcher" minSdkVersion 26 - // 28 is not a leftover. An app on targetSdk 29+ runs in a domain that may not execute a - // file out of its own data directory, and this app's entire job is to exec a rootfs: - // proot, gamescope, the Steam client and everything it launches all live under files/. - targetSdkVersion 28 + targetSdkVersion 36 versionCode 12 versionName "0.3.2" buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\"" @@ -152,14 +149,6 @@ android { } } - lint { - // targetSdk 28 is deliberate and structural: an app on 29+ may not execute a file out of - // its own data directory, and executing a rootfs is the whole of what this app does. Lint - // fails a release build over it because Google Play will not accept an sdk that old - - // true, and not something this app can trade away. Every other check stays on. - disable 'ExpiredTargetSdkVersion' - } - packagingOptions { resources { // Kadb's Bouncy Castle artifacts repeat the same multi-release JAR manifest. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d833ca2ae..41f0cd704 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,8 @@ process at perceptible priority, and a high-performance WiFi lock keeps a backgrounded download from being throttled to nothing by the radio's power-save. --> + + @@ -30,9 +32,9 @@ - - + + + @@ -47,6 +49,7 @@ android:hardwareAccelerated="true" android:largeHeap="true" android:extractNativeLibs="true" + android:requestLegacyExternalStorage="true" android:appCategory="game" android:isGame="true"> @@ -167,14 +170,25 @@ + android:foregroundServiceType="specialUse" + android:stopWithTask="false"> + + + android:exported="false" + android:foregroundServiceType="dataSync" /> + android:exported="false" + android:foregroundServiceType="specialUse"> + + () - if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + && checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !SessionPrefs.bluetoothAsked(this) + && checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { + wanted.add(Manifest.permission.BLUETOOTH_CONNECT) + SessionPrefs.setBluetoothAsked(this) + } // The microphone is off by default. Someone who turned it on before the permission was // granted is asked once here, with the storage prompt; a refusal is not asked again - the // toggle asks when used. diff --git a/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java b/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java index c4e1db8c7..a86f0b736 100644 --- a/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java +++ b/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java @@ -156,7 +156,7 @@ public class PulseAudioComponent extends SessionPart { env.add("HOME=" + workingDir); env.add("TMPDIR=" + workingDir); - String command = workingDir.getAbsolutePath() + "/libpulseaudio.so" + String command = HostProcess.LINKER + " " + workingDir.getAbsolutePath() + "/libpulseaudio.so" + " --system=false --disable-shm=true --fail=false" + " -n --file=default.pa --daemonize=false --use-pid-file=false --exit-idle-time=-1" // Info level: the classic sink reports the stream Android granted (burst, capacity, @@ -216,6 +216,7 @@ public class PulseAudioComponent extends SessionPart { try { File modules = new File(workingDir, "modules/arm64"); ArrayList cmd = new ArrayList<>(); + cmd.add(HostProcess.LINKER); cmd.add(pactl.getAbsolutePath()); java.util.Collections.addAll(cmd, args); ProcessBuilder builder = new ProcessBuilder(cmd); @@ -269,7 +270,7 @@ public class PulseAudioComponent extends SessionPart { try { File modules = new File(workingDir, "modules/arm64"); ProcessBuilder builder = new ProcessBuilder( - pactl.getAbsolutePath(), "suspend-sink", "@DEFAULT_SINK@", Boolean.toString(suspended)); + HostProcess.LINKER, pactl.getAbsolutePath(), "suspend-sink", "@DEFAULT_SINK@", Boolean.toString(suspended)); builder.directory(workingDir); builder.redirectErrorStream(true); builder.redirectOutput(new File("/dev/null")); diff --git a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt index 550d55a52..dd01cdd37 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt @@ -14,6 +14,8 @@ import java.util.function.Consumer * block the child the moment the pipe filled. */ object HostProcess { + const val LINKER = "/system/bin/linker64" + private const val TAG = "HostProcess" /** diff --git a/app/src/main/java/com/droiddeck/launcher/core/WifiDiscovery.kt b/app/src/main/java/com/droiddeck/launcher/core/WifiDiscovery.kt index 40a6721dc..a4d5d9d76 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/WifiDiscovery.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/WifiDiscovery.kt @@ -11,10 +11,10 @@ import com.droiddeck.launcher.session.SessionPrefs object WifiDiscovery { val permissions = arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION) - // targetSdk 28 accepts either grant for Wi-Fi scanning. - fun permissionGranted(context: Context): Boolean = permissions.any { - context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED - } + fun permissionGranted(context: Context): Boolean = + (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) else permissions).any { + context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } fun locationEnabled(context: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.P || (context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager)?.isLocationEnabled == true diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt index 8f8782719..e47b844d5 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt @@ -36,7 +36,6 @@ class FilePickerActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this) - // targetSdk 28: the old storage permission is exactly what lets us list the card. if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1) } diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt index 34357e48a..05bd63831 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt @@ -26,7 +26,7 @@ import java.util.UUID * * Lizard mode is refreshed while connected; when a session ends, the controller restores it itself. */ -@SuppressLint("MissingPermission") // BLUETOOTH is granted at install below targetSdk 31 +@SuppressLint("MissingPermission") class SteamControllerBle(private val context: Context) { private val thread = HandlerThread("steam-controller-ble").apply { start() } private val handler = Handler(thread.looper) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index 082bf814e..00eb52bff 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -20,8 +20,7 @@ import java.util.List; * The glibc arm64 rootfs at {@code files/linuxfs} and the proot invocation that runs a program in * it as this app's own uid. It is a second runtime beside the Wine imagefs, not a container: no * Wine, no box64, no FEX. proot is packaged as {@code libproot.so} so the installer places it, with - * its loader, in the native library directory - the only place an app on targetSdk 28 may execute - * a file from. + * its loader, in the native library directory. * *

Ported from WinNative's gamescope runtime (GPL-3.0). */ diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index cb2e1cbd6..8b2c01e54 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -259,6 +259,12 @@ object SessionPrefs { prefs(context).edit().putBoolean("micAsked", true).apply() } + fun bluetoothAsked(context: Context): Boolean = prefs(context).getBoolean("bluetoothAsked", false) + + fun setBluetoothAsked(context: Context) { + prefs(context).edit().putBoolean("bluetoothAsked", true).apply() + } + fun setMicEnabled(context: Context, on: Boolean) { prefs(context).edit().putBoolean("mic", on).apply() } diff --git a/tools/linuxfs/preload/noexec.c b/tools/linuxfs/preload/noexec.c index 26aa539b7..fba3a4e73 100644 --- a/tools/linuxfs/preload/noexec.c +++ b/tools/linuxfs/preload/noexec.c @@ -2,14 +2,18 @@ * noexec.c: FEX runs an x86 program and its libraries where they lie, on noexec shared storage too. * FEX maps them with the guest's PROT_EXEC, which the kernel refuses for a file on a noexec mount, * but it never runs those pages itself - it runs its own translation of them - so a mapping or an - * mprotect refused for that alone is made without PROT_EXEC. Only in FEX's own processes. + * mprotect refused for that alone is made without PROT_EXEC. */ #define _GNU_SOURCE #include #include #include #include +#include +#include +#include #include +#include #include /* NOEXEC_TEST: tools/tests builds it for the host. */ @@ -45,11 +49,60 @@ __attribute__((constructor)) static void noexec_init(void) { real_mprotect = (int (*)(void *, size_t, int))dlsym(RTLD_NEXT, "mprotect"); } +static int private_file_mapping(uintptr_t start, uintptr_t end) { + int fd = (int)syscall(SYS_openat, AT_FDCWD, "/proc/self/maps", O_RDONLY | O_CLOEXEC); + if (fd < 0) return 0; + char buf[4096], line[1024]; + size_t used = 0; + int found = 0, done = 0; + ssize_t n; + while (!done && (n = syscall(SYS_read, fd, buf, sizeof buf)) > 0) { + for (ssize_t i = 0; i < n && !done; i++) { + if (buf[i] != '\n') { + if (used < sizeof line - 1) line[used++] = buf[i]; + continue; + } + line[used] = 0; + used = 0; + char *p = line; + uintptr_t lo = strtoull(p, &p, 16), hi = strtoull(p + 1, &p, 16); + if (start < lo || start >= hi) continue; + done = 1; + char *perms = p + 1; + if (end > hi || perms[3] != 'p') break; + char *path = strchr(perms, '/'); + struct statvfs vfs; + found = path && !(statvfs(path, &vfs) == 0 && (vfs.f_flag & ST_NOEXEC)); + } + } + syscall(SYS_close, fd); + return found; +} + +static int rebake(void *addr, size_t len, int prot) { + if (real_mprotect(addr, len, PROT_READ)) return -1; + void *copy = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (copy == MAP_FAILED) return -1; + memcpy(copy, addr, len); + if (mmap(addr, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0) == MAP_FAILED) { + munmap(copy, len); + return -1; + } + memcpy(addr, copy, len); + munmap(copy, len); + __builtin___clear_cache((char *)addr, (char *)addr + len); + return real_mprotect(addr, len, prot); +} + int mprotect(void *addr, size_t len, int prot) { if (!real_mprotect) noexec_init(); int result = real_mprotect(addr, len, prot); - if (result && errno == EACCES && (prot & PROT_EXEC) && in_fex()) { - result = real_mprotect(addr, len, prot & ~PROT_EXEC); + if (result && errno == EACCES && (prot & PROT_EXEC)) { + if (in_fex()) { + result = real_mprotect(addr, len, prot & ~PROT_EXEC); + } else if (len && private_file_mapping((uintptr_t)addr, (uintptr_t)addr + len)) { + result = rebake(addr, len, prot); + } if (result) errno = EACCES; } return result; From 8c16a04d8d96e24d868c408891c6708e3e95050b Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sun, 11 Oct 2026 02:07:23 -0400 Subject: [PATCH 2/8] fix: data sync services stop cleanly when Android 15 ends their daily time --- .../droiddeck/launcher/runtime/RuntimeInstallService.kt | 5 +++++ .../launcher/stores/download/StoreDownloadService.kt | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt index aef352baa..6b13c0378 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt @@ -27,6 +27,11 @@ class RuntimeInstallService : Service() { override fun onBind(intent: Intent?): IBinder? = null + override fun onTimeout(startId: Int, fgsType: Int) { + stopForeground(STOP_FOREGROUND_DETACH) + stopSelf() + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val removal = intent?.action == ACTION_REMOVE val release = intent?.let(::releaseFrom) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt index e8c71f248..c26e7e983 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt @@ -82,6 +82,15 @@ class StoreDownloadService : Service() { override fun onBind(intent: Intent?): IBinder? = null + override fun onTimeout(startId: Int, fgsType: Int) { + Log.w(TAG, "data sync time used up; pausing downloads") + StoresState.latestDownloads.filter { it.state == DownloadState.RUNNING }.forEach { + DownloadQueue.pause(it.key) + pausedNote(this, it.key) + } + stopNow() + } + private fun pending(action: String, key: String, code: Int): PendingIntent { val i = Intent(this, StoreDownloadService::class.java).setAction(action).putExtra(EXTRA_KEY, key) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) PendingIntent.getForegroundService(this, code, i, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) From 30270ca654e4bf0ef2cf000230fec3eb5a78863a Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sun, 11 Oct 2026 02:07:23 -0400 Subject: [PATCH 3/8] test: pin Robolectric to SDK 35 for the Java 17 build --- app/src/test/resources/robolectric.properties | 1 + 1 file changed, 1 insertion(+) create mode 100644 app/src/test/resources/robolectric.properties diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 000000000..3f67ea5ac --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=35 From dfc0b95557c668f907d63143536420b9003c34b6 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sun, 11 Oct 2026 10:08:10 -0400 Subject: [PATCH 4/8] feat: ask for all files access and offer storage and Bluetooth permissions in Setup --- app/src/main/AndroidManifest.xml | 1 + .../com/droiddeck/launcher/MainActivity.kt | 34 ++++++++++++++ .../droiddeck/launcher/core/AllFilesAccess.kt | 45 +++++++++++++++++++ .../launcher/files/FileManagerActivity.kt | 5 ++- .../launcher/files/FilePickerActivity.kt | 5 ++- .../launcher/session/SessionPrefs.kt | 6 +++ .../droiddeck/launcher/ui/FrontEndScreen.kt | 4 ++ .../droiddeck/launcher/ui/FrontEndSetup.kt | 6 +++ app/src/main/res/values/strings.xml | 8 ++++ 9 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/core/AllFilesAccess.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 41f0cd704..4d72453da 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 1bd84b525..7696470dd 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -182,6 +182,7 @@ class MainActivity : ComponentActivity() { private val wifiLocationReceiver = object : android.content.BroadcastReceiver() { override fun onReceive(context: android.content.Context?, intent: Intent?) { refreshWifiDiscovery() + refreshPermissionRows() } } private val mediaRefresh = Runnable { @@ -370,6 +371,8 @@ class MainActivity : ComponentActivity() { private var runningLabel by mutableStateOf(null) private var logsEnabled by mutableStateOf(true) private var agentCommands by mutableStateOf(false) + private var bluetoothAllowed by mutableStateOf(null) + private var allFilesAllowed by mutableStateOf(null) private var showRoms by mutableStateOf(false) private var homeAppSelected by mutableStateOf(false) private var homeScreenEnabled by mutableStateOf(false) @@ -569,6 +572,7 @@ class MainActivity : ComponentActivity() { desktopInstalled = desktopInstalled, desktopPresent = desktopPresent, offlineAccount = offlineAccount, offline = offline, frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands, + bluetoothAllowed = bluetoothAllowed, allFilesAllowed = allFilesAllowed, steamGames = steamGames, emulators = emulatorList, running = runningLabel, frameGen = FrameGen.mode(this), lossless = lossless, @@ -716,6 +720,8 @@ class MainActivity : ComponentActivity() { com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)) agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this) }, + onBluetoothAllowed = { askBluetooth() }, + onAllFilesAllowed = { com.droiddeck.launcher.core.AllFilesAccess.openSettings(this) }, onShareLogs = { SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip -> if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show() @@ -913,6 +919,14 @@ class MainActivity : ComponentActivity() { SessionPrefs.setMicAsked(this) } if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1) + else if (!com.droiddeck.launcher.core.AllFilesAccess.granted() && !SessionPrefs.allFilesAsked(this)) { + Thread({ + if (com.droiddeck.launcher.core.AllFilesAccess.usesSharedStorage(this)) runOnUiThread { + SessionPrefs.setAllFilesAsked(this) + com.droiddeck.launcher.core.AllFilesAccess.ask(this) + } + }, "all-files-access").start() + } // A store sign-in an earlier build left in plain text is sealed with the Keystore key. Thread({ com.droiddeck.launcher.stores.StoreAccounts.encryptAll(this) }, "store-credentials").start() // A session folder left without its ending - the process was killed - gets it now. @@ -993,6 +1007,26 @@ class MainActivity : ComponentActivity() { } } + private fun refreshPermissionRows() { + bluetoothAllowed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED else null + allFilesAllowed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) com.droiddeck.launcher.core.AllFilesAccess.granted() else null + } + + private fun askBluetooth() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + val permission = Manifest.permission.BLUETOOTH_CONNECT + val granted = checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED + if (granted || (SessionPrefs.bluetoothAsked(this) && !shouldShowRequestPermissionRationale(permission))) { + runCatching { + startActivity(Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.parse("package:$packageName"))) + } + } else { + SessionPrefs.setBluetoothAsked(this) + requestPermissions(arrayOf(permission), 3) + } + } + override fun onResume() { super.onResume() orientation = SessionPrefs.orientation(this) diff --git a/app/src/main/java/com/droiddeck/launcher/core/AllFilesAccess.kt b/app/src/main/java/com/droiddeck/launcher/core/AllFilesAccess.kt new file mode 100644 index 000000000..2a905dee7 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/AllFilesAccess.kt @@ -0,0 +1,45 @@ +package com.droiddeck.launcher.core + +import android.app.Activity +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.AddedExes +import com.droiddeck.launcher.stores.StoreInstallRoot + +object AllFilesAccess { + fun granted(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager() + + fun usesSharedStorage(context: Context): Boolean { + val own = (listOf(context.filesDir.parentFile) + context.getExternalFilesDirs(null).map { it?.parentFile }) + .filterNotNull().map { it.absolutePath + "/" } + fun shared(path: String) = own.none { (path + "/").startsWith(it) } + return AddedExes.list(context).any { shared(it.folder) } || + StoreInstallRoot.roots(context).any { shared(it.absolutePath) } + } + + fun ask(activity: Activity) { + if (granted() || activity.isFinishing) return + AlertDialog.Builder(activity) + .setTitle(R.string.all_files_title) + .setMessage(R.string.all_files_message) + .setPositiveButton(R.string.all_files_allow) { _, _ -> openSettings(activity) } + .setNegativeButton(R.string.all_files_later, null) + .show() + } + + fun openSettings(activity: Activity) { + val forApp = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:" + activity.packageName)) + try { + activity.startActivity(forApp) + } catch (e: ActivityNotFoundException) { + runCatching { activity.startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)) } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt index 2395d1c80..f48fd721e 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.files import android.Manifest import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -23,7 +24,9 @@ class FileManagerActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this) - if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + com.droiddeck.launcher.core.AllFilesAccess.ask(this) + } else if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1) } val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File) diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt index e47b844d5..9e70f9550 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt @@ -4,6 +4,7 @@ import android.Manifest import android.app.Activity import android.content.Intent import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -36,7 +37,9 @@ class FilePickerActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this) - if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + com.droiddeck.launcher.core.AllFilesAccess.ask(this) + } else if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 8b2c01e54..f207c5cb4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -259,6 +259,12 @@ object SessionPrefs { prefs(context).edit().putBoolean("micAsked", true).apply() } + fun allFilesAsked(context: Context): Boolean = prefs(context).getBoolean("allFilesAsked", false) + + fun setAllFilesAsked(context: Context) { + prefs(context).edit().putBoolean("allFilesAsked", true).apply() + } + fun bluetoothAsked(context: Context): Boolean = prefs(context).getBoolean("bluetoothAsked", false) fun setBluetoothAsked(context: Context) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 1c2e50dec..3baa6cd60 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -97,6 +97,8 @@ class FrontEndState( val logsEnabled: Boolean, /** Setup's Debugging tools toggle (AgentAccess): off unless the user turns it on. */ val agentCommands: Boolean = false, + val bluetoothAllowed: Boolean? = null, + val allFilesAllowed: Boolean? = null, val steamGames: List, val emulators: List, val running: String?, @@ -198,6 +200,8 @@ class FrontEndActions( val onBrowseFiles: (File) -> Unit = {}, val onLogs: () -> Unit, val onAgentCommands: () -> Unit = {}, + val onBluetoothAllowed: () -> Unit = {}, + val onAllFilesAllowed: () -> Unit = {}, val onShareLogs: () -> Unit = {}, val onClearLogs: () -> Unit = {}, val onOffline: () -> Unit, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index c0eb21e92..23e994aaa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -311,6 +311,12 @@ internal fun SetupPanel( } ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() } ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() } + s.allFilesAllowed?.let { on -> + ToggleRow(host, "all-files", stringResource(R.string.setup_all_files), stringResource(R.string.setup_all_files_hint), on) { a.onAllFilesAllowed() } + } + s.bluetoothAllowed?.let { on -> + ToggleRow(host, "bluetooth", stringResource(R.string.setup_bluetooth), stringResource(R.string.setup_bluetooth_hint), on) { a.onBluetoothAllowed() } + } ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs, progress = com.droiddeck.launcher.session.SessionLogShare.progress) ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f9e1b604..8c753ecbe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1727,6 +1727,14 @@ 6-digit pairing code Tap Enter code and type the 6 digits shown in Settings. + All files access + Lets DroidDeck see games and programs on your storage and SD card + Bluetooth Steam Controller + Lets DroidDeck find a paired Steam Controller (Nearby devices) + Allow access to your game folders + DroidDeck runs games and programs from your phone\'s storage and SD card. Android only lets it see those files with All files access, which you can turn on for DroidDeck on the next screen. + Open settings + Not now Enter the Wi-Fi pairing code Try the code again From e079f9602f669d21797f6b47cc0c9b57549ff95f Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sun, 11 Oct 2026 10:08:10 -0400 Subject: [PATCH 5/8] fix: a session starts from the last added-games listing and refreshes it in the background --- .../droiddeck/launcher/frontend/AddedGames.kt | 11 ++++++++-- .../launcher/frontend/AddedGamesPublisher.kt | 22 +++++++++++++++++++ .../launcher/session/SessionService.kt | 13 ++++++++--- .../launcher/stores/SteamLiveShortcuts.kt | 8 ++++--- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index a4ce68acb..f83a046b3 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -490,8 +490,10 @@ object AddedGames { } /** The list the session hands the runtime's shortcuts writer; one file per session start. */ + fun listingFile(context: Context): File = File(context.filesDir, "session/added-games.json") + fun writeListing(context: Context, games: List): File { - val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() } + val file = listingFile(context).apply { parentFile?.mkdirs() } val json = StringBuilder("[") games.forEachIndexed { i, g -> if (i > 0) json.append(',') @@ -508,7 +510,12 @@ object AddedGames { if (pieces.isNotEmpty()) json.append(",\"art\":{").append(pieces.joinToString(",") { (k, v) -> quote(k) + ":" + quote(v) }).append('}') json.append('}') } - file.writeText(json.append(']').toString()) + val staged = File(file.path + ".tmp") + staged.writeText(json.append(']').toString()) + if (!staged.renameTo(file)) { + file.writeText(staged.readText()) + staged.delete() + } return file } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGamesPublisher.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGamesPublisher.kt index 19243b8e8..e33933444 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGamesPublisher.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGamesPublisher.kt @@ -31,6 +31,28 @@ object AddedGamesPublisher { if (now) work.run() else worker.execute(work) } + fun refreshDuringSession(context: Context) { + val app = context.applicationContext + worker.execute { + try { + val before = AddedGames.listingFile(app).takeIf { it.isFile }?.readText().orEmpty() + val games = AddedGames.scan(app) + AddedGames.writeListing(app, games) + LibraryCache.save(app, Library.launchableGames(app, added = games)) + val fresh = games.filter { !before.contains(org.json.JSONObject.quote(it.guestExe)) } + if (fresh.isEmpty()) return@execute + Log.i(TAG, "session refresh: ${fresh.size} game(s) not in the listing the session started with") + for (attempt in 0 until 36) { + if (com.droiddeck.launcher.stores.SteamLiveShortcuts.sync(app, fresh) { true }) break + if (!com.droiddeck.launcher.session.SessionState.running) break + Thread.sleep(5_000) + } + } catch (e: Exception) { + Log.w(TAG, "session refresh: ${e.message}") + } + } + } + /** The listing and the library cache from one scan of the added games. Blocking. */ fun republish(context: Context) { val games = AddedGames.scan(context) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 491001561..2b2330419 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -385,12 +385,19 @@ class SessionService : Service() { // The user's own games, for the runtime's shortcuts writer to put in the client's library // before the client starts (see frontend/AddedGames and droiddeck-steam-shortcuts). if (steamHere) { - val added = com.droiddeck.launcher.frontend.AddedGames.scan(this) - val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added) + val cached = com.droiddeck.launcher.frontend.AddedGames.listingFile(this) + val listing = if (cached.isFile) { + com.droiddeck.launcher.frontend.AddedGamesPublisher.refreshDuringSession(this) + Log.i(TAG, "added games: the last listing, refreshed in the background") + cached + } else { + val added = com.droiddeck.launcher.frontend.AddedGames.scan(this) + if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" }) + com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added) + } guest.add("BL_ADDED_GAMES=" + listing.path) if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1") if (com.droiddeck.launcher.gpu.Lossless.owned(this)) guest.add("BL_LOSSLESS_OWNED=1") - if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" }) } // Where the guest leaves a request for another session (the desktop's Steam launchers). guest.add("BL_LAUNCH_DIR=" + sessionRoot.path) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt index 250a37346..90983425a 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt @@ -37,9 +37,10 @@ object SteamLiveShortcuts { private const val COMPAT_TOOL = "droiddeck-proton-arm64" /** Adds every game in [games] that [include] takes (store games by default) and the client does not list yet. Blocking; worker thread. */ - fun sync(context: Context, games: List, include: (AddedGames.Game) -> Boolean = { it.source != Library.ADDED }) { + fun sync(context: Context, games: List, include: (AddedGames.Game) -> Boolean = { it.source != Library.ADDED }): Boolean { val wanted = games.filter(include) - if (wanted.isEmpty() || !clientRunning()) return + if (wanted.isEmpty()) return true + if (!clientRunning()) return false val list = JSONArray() for (g in wanted) list.put(JSONObject().put("name", g.name).put("exe", g.guestExe).put("dir", g.guestDir)) val js = """ @@ -59,9 +60,10 @@ object SteamLiveShortcuts { return added; })() """.trimIndent() - val result = evaluate(context, js) ?: return + val result = evaluate(context, js) ?: return false Log.i(TAG, "live shortcuts: $result added") if (result != "0") StoresState.logLine("Steam: $result shortcut(s) added to the running client") + return true } /** Removes the shortcut with [appId] from the running client. Blocking; worker thread. */ From a760aea276db81f8d1f61ac5aa4ff3e1dae74d66 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sun, 11 Oct 2026 10:08:10 -0400 Subject: [PATCH 6/8] perf: relocated Wine code is shared between processes through a per-session cache file --- .../overlay/usr/local/bin/droiddeck-session | 1 + tools/linuxfs/preload/noexec.c | 75 +++++++++++ tools/tests/test_noexec_preload.py | 118 ++++++++++++++++++ 3 files changed, 194 insertions(+) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 94d9a6937..6a27e37e4 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -114,6 +114,7 @@ fi # with the same flags, so no priority reaches the GPU. Kept for a driver that maps it, and off # unless the app's Performance page turns it on (BL_GAMESCOPE_REALTIME=1). export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-0} +rm -rf /tmp/.droiddeck-rx # A sleep that starts no process. Under proot every exec is a traced execve plus the loader's # opens and maps, and the watchers below poll for the whole session: `sleep 1` in a loop was 60 diff --git a/tools/linuxfs/preload/noexec.c b/tools/linuxfs/preload/noexec.c index fba3a4e73..62ed3ae62 100644 --- a/tools/linuxfs/preload/noexec.c +++ b/tools/linuxfs/preload/noexec.c @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include #include #include @@ -79,8 +81,81 @@ static int private_file_mapping(uintptr_t start, uintptr_t end) { return found; } +static const char rx_dir[] = "/tmp/.droiddeck-rx"; + +static uint64_t fnv1a(const unsigned char *p, size_t n) { + uint64_t h = 1469598103934665603ULL; + for (size_t i = 0; i < n; i++) { + h ^= p[i]; + h *= 1099511628211ULL; + } + return h; +} + +static char *put_hex(char *out, uint64_t v) { + for (int shift = 60; shift >= 0; shift -= 4) *out++ = "0123456789abcdef"[(v >> shift) & 15]; + return out; +} + +static int same_content(int fd, const unsigned char *p, size_t len) { + char buf[16384]; + for (size_t off = 0; off < len;) { + size_t want = len - off < sizeof buf ? len - off : sizeof buf; + if (syscall(SYS_pread64, fd, buf, want, off) != (long)want || memcmp(buf, p + off, want)) return 0; + off += want; + } + return 1; +} + +static int write_all(int fd, const unsigned char *p, size_t len) { + for (size_t off = 0; off < len;) { + long n = syscall(SYS_write, fd, p + off, len - off); + if (n <= 0) return -1; + off += (size_t)n; + } + return 0; +} + +static int rebake_shared(void *addr, size_t len, int prot) { + char path[sizeof rx_dir + 40], staged[sizeof path + 20]; + char *end = path + sizeof rx_dir - 1; + memcpy(path, rx_dir, sizeof rx_dir - 1); + *end++ = '/'; + end = put_hex(end, fnv1a(addr, len)); + *end++ = '-'; + end = put_hex(end, len); + *end = 0; + int fd = (int)syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC); + if (fd >= 0 && !same_content(fd, addr, len)) { + syscall(SYS_close, fd); + return -1; + } + if (fd < 0) { + syscall(SYS_mkdirat, AT_FDCWD, rx_dir, 0700); + memcpy(staged, path, (size_t)(end - path)); + char *tail = put_hex(staged + (end - path), (uint64_t)syscall(SYS_gettid)); + *tail = 0; + int out = (int)syscall(SYS_openat, AT_FDCWD, staged, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600); + if (out < 0) return -1; + int bad = write_all(out, addr, len); + syscall(SYS_close, out); + if (bad || syscall(SYS_renameat, AT_FDCWD, staged, AT_FDCWD, path)) { + syscall(SYS_unlinkat, AT_FDCWD, staged, 0); + return -1; + } + fd = (int)syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC); + if (fd < 0) return -1; + } + long mapped = syscall(SYS_mmap, addr, len, prot, MAP_PRIVATE | MAP_FIXED, fd, 0); + syscall(SYS_close, fd); + return mapped == (long)addr ? 0 : -1; +} + static int rebake(void *addr, size_t len, int prot) { + size_t page = getauxval(AT_PAGESZ); + if (page) len = (len + page - 1) & ~(page - 1); if (real_mprotect(addr, len, PROT_READ)) return -1; + if (!rebake_shared(addr, len, prot)) return 0; void *copy = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (copy == MAP_FAILED) return -1; memcpy(copy, addr, len); diff --git a/tools/tests/test_noexec_preload.py b/tools/tests/test_noexec_preload.py index 0e563ec49..fb5ecc764 100644 --- a/tools/tests/test_noexec_preload.py +++ b/tools/tests/test_noexec_preload.py @@ -70,5 +70,123 @@ class NoexecPreloadTest(unittest.TestCase): self.assertEqual(refused[1:], ["protect 13", "read -"]) +EXECMOD = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +static int private_file(void *addr) { + FILE *maps = fopen("/proc/self/maps", "r"); + char line[512]; + int found = 0; + while (maps && fgets(line, sizeof line, maps)) { + unsigned long lo, hi; + char perms[5]; + if (sscanf(line, "%lx-%lx %4s", &lo, &hi, perms) != 3) continue; + if ((unsigned long)addr >= lo && (unsigned long)addr < hi) { + found = perms[3] == 'p' && strchr(line, '/') != NULL; + break; + } + } + if (maps) fclose(maps); + return found; +} + +int mprotect(void *addr, size_t len, int prot) { + if ((prot & PROT_EXEC) && private_file(addr)) { + errno = EACCES; + return -1; + } + return (int)syscall(SYS_mprotect, addr, len, prot); +} +""" + +CALLER = r""" +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + int fd = open(argv[1], O_RDWR); + unsigned char *code = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0); +#if defined(__aarch64__) + static const unsigned int body[] = {0x52800540, 0xd65f03c0}; +#else + static const unsigned char body[] = {0xb8, 0x2a, 0x00, 0x00, 0x00, 0xc3}; +#endif + memcpy(code + 64, body, sizeof body); + int result = mprotect(code, 4096, PROT_READ | PROT_EXEC); + printf("protect %d\n", result ? errno : 0); + __builtin___clear_cache((char *)code, (char *)code + 4096); + if (!result) printf("call %d\n", ((int (*)(void))(code + 64))()); + printf("data %d\n", code[0]); + char line[512]; + FILE *maps = fopen("/proc/self/maps", "r"); + while (maps && fgets(line, sizeof line, maps)) { + unsigned long lo, hi; + if (sscanf(line, "%lx-%lx", &lo, &hi) == 2 && (unsigned long)code >= lo && (unsigned long)code < hi) + printf("shared %d\n", strstr(line, ".droiddeck-rx") != NULL); + } + return 0; +} +""" + + +@unittest.skipUnless(shutil.which("cc") and os.uname().sysname == "Linux", "needs cc on Linux") +class ExecmodRebakeTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = Path(tempfile.mkdtemp()) + cls.lib = cls.tmp / "libnoexec.so" + subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-fPIC", "-shared", "-pthread", "-DNOEXEC_TEST", "-o", str(cls.lib), + str(PRELOAD / "noexec.c"), str(PRELOAD / "fsync.c"), str(PRELOAD / "robust.c"), "-ldl"], check=True) + (cls.tmp / "execmod.c").write_text(EXECMOD) + cls.execmod = cls.tmp / "libexecmod.so" + subprocess.run(["cc", "-O2", "-fPIC", "-shared", "-o", str(cls.execmod), str(cls.tmp / "execmod.c")], check=True) + (cls.tmp / "caller.c").write_text(CALLER) + cls.caller = cls.tmp / "wine" + subprocess.run(["cc", "-O2", "-o", str(cls.caller), str(cls.tmp / "caller.c")], check=True) + cls.image = cls.tmp / "ntdll.dll" + cls.image.write_bytes(bytes([7]) + bytes(4095)) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmp, ignore_errors=True) + + def setUp(self): + shutil.rmtree("/tmp/.droiddeck-rx", ignore_errors=True) + + def tearDown(self): + shutil.rmtree("/tmp/.droiddeck-rx", ignore_errors=True) + + def run_with(self, *libs): + env = dict(os.environ, LD_PRELOAD=":".join(str(lib) for lib in libs)) + result = subprocess.run([str(self.caller), str(self.image)], env=env, capture_output=True, text=True, check=True) + return result.stdout.split() + + def test_a_refused_relocated_image_runs_from_anonymous_memory(self): + self.assertEqual(self.run_with(self.lib, self.execmod), ["protect", "0", "call", "42", "data", "7", "shared", "1"]) + + def test_processes_share_one_copy_of_the_code(self): + self.run_with(self.lib, self.execmod) + self.run_with(self.lib, self.execmod) + self.assertEqual(len(os.listdir("/tmp/.droiddeck-rx")), 1) + + def test_without_the_preload_the_refusal_stands(self): + self.assertEqual(self.run_with(self.execmod), ["protect", "13", "data", "7", "shared", "0"]) + + def test_the_file_itself_is_not_changed(self): + self.run_with(self.lib, self.execmod) + self.assertEqual(self.image.read_bytes()[:65], bytes([7]) + bytes(64)) + + if __name__ == "__main__": unittest.main() From 9922823090349ab2652667a617d7d04bc37ba16b Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sun, 11 Oct 2026 10:44:19 -0400 Subject: [PATCH 7/8] fix: the Setup permission rows refresh when the app returns to the front --- app/src/main/java/com/droiddeck/launcher/MainActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 7696470dd..f9e230d24 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -182,7 +182,6 @@ class MainActivity : ComponentActivity() { private val wifiLocationReceiver = object : android.content.BroadcastReceiver() { override fun onReceive(context: android.content.Context?, intent: Intent?) { refreshWifiDiscovery() - refreshPermissionRows() } } private val mediaRefresh = Runnable { @@ -1032,6 +1031,7 @@ class MainActivity : ComponentActivity() { orientation = SessionPrefs.orientation(this) requestedOrientation = orientation refreshWifiDiscovery() + refreshPermissionRows() // Back from a store's sign-in page: the Stores chips show the account at once. A store // install finishing while this screen is up rebuilds the Games list through the listener. if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this) From 1ca2d9bd49373dff82d01ac07524fe1060597bc6 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Tue, 13 Oct 2026 21:44:00 -0400 Subject: [PATCH 8/8] Remove stale comments from statx patch --- .../patches/0015-statx-mnt-id-fdinfo.patch | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/tools/proot/patches/0015-statx-mnt-id-fdinfo.patch b/tools/proot/patches/0015-statx-mnt-id-fdinfo.patch index aa5b564f8..a0821997a 100644 --- a/tools/proot/patches/0015-statx-mnt-id-fdinfo.patch +++ b/tools/proot/patches/0015-statx-mnt-id-fdinfo.patch @@ -11,20 +11,8 @@ diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c index ee7b8acd68660f38fd52f56e951ffb94e9061d68..ff3a79b60e9092a296bb444c919bb3861338727f 100644 --- a/src/syscall/seccomp.c +++ b/src/syscall/seccomp.c -@@ -738,4 +738,4 @@ int enable_syscall_filtering(const Tracee *tracee) -- * the statx one emulates the call when it fails, which only a kernel -- * before 4.11 does for want of it - a statx Android's policy traps is -- * emulated from SIGSYS instead. Both stops re-read and re-translate -- * the path, and are paid on every call. -+ * the statx one emulates the syscall before 4.11 and supplies MNT_ID -+ * from fdinfo through 5.7. Android policy traps are still emulated from -+ * SIGSYS instead. Both stops re-read and re-translate the path, and are -+ * paid on every call. -@@ -764 +764,4 @@ static void drop_unneeded_exit_stops(void) +@@ -764 +764 @@ static void drop_unneeded_exit_stops(void) - if (major > 4 || (major == 4 && minor >= 11)) -+ /* Linux 4.11 introduced statx; Linux 5.8 added MNT_ID. -+ * Keep its exit stop through 5.7 so the missing ID can be -+ * recovered from the descriptor's fdinfo. */ + if (major > 5 || (major == 5 && minor >= 8)) diff --git a/src/tracee/statx.c b/src/tracee/statx.c index ca972f892e84461cb694dd13a37bfbc67527b320..824771ccf5934e1f870fc36a29fb22fdcad0a856 100644 @@ -32,11 +20,7 @@ index ca972f892e84461cb694dd13a37bfbc67527b320..824771ccf5934e1f870fc36a29fb22fd +++ b/src/tracee/statx.c @@ -4,0 +5 @@ +#include "tracee/statx_mnt_id.h" -@@ -125,0 +127,9 @@ int handle_statx_syscall(Tracee *tracee, bool from_sigsys) { -+ /* libglnx chases an already-open fd with AT_EMPTY_PATH and requires a -+ * mount ID. Kernels before 5.8 can succeed without returning that field. -+ * The tracee is stopped at this syscall exit; accept only the ordinary -+ * mount ID and verify the fdinfo inode against the statx result. */ +@@ -125,0 +127,5 @@ int handle_statx_syscall(Tracee *tracee, bool from_sigsys) { + if (droiddeck_proot_statx_fill_mnt_id(tracee->pid, (int) dirfd, + (int) flags, guest_path, (unsigned int) mask, + &state.statx_buf))