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;