feat: target Android API 36

This commit is contained in:
xXJsonDeruloXx committed 2026-10-11 02:07:23 -04:00
1 parent e5ba165027
commit 0fd50d76b2
11 files changed
+103 -35

No files matched your search

+2 -13
View File
@@ -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.
+20 -6
View File
@@ -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. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Optional Wi-Fi discovery: requested only from Steam settings after an explanation. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
@@ -30,9 +32,9 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- A paired Steam Controller, read over Bluetooth LE out of lizard mode (SteamControllerBle).
Granted at install below targetSdk 31; no scan, so no location. -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<!-- A paired Steam Controller, read over Bluetooth LE out of lizard mode (SteamControllerBle). -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
@@ -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 @@
<service
android:name=".session.SessionService"
android:exported="false"
android:stopWithTask="false" />
android:foregroundServiceType="specialUse"
android:stopWithTask="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Runs the user's Linux game session (compositor, Steam client, audio) while the app is not on screen" />
</service>
<!-- Holds the process in the foreground while the Linux runtime downloads and unpacks. -->
<service
android:name=".runtime.RuntimeInstallService"
android:exported="false" />
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".core.WirelessAdbPairingService"
android:exported="false" />
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Waits for the wireless debugging pairing code the user enters from the notification" />
</service>
<!-- Keeps the process alive while a store (GOG, Epic, Amazon) game downloads. -->
<service
android:name=".stores.download.StoreDownloadService"
@@ -893,12 +893,17 @@ class MainActivity : ComponentActivity() {
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
// The session's logs land in Downloads so a failed run can be handed over as a folder
// rather than dug out of app-private storage. targetSdk 28 means the old permission still
// grants exactly that.
// rather than dug out of app-private storage.
val wanted = ArrayList<String>()
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.
@@ -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<String> 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"));
@@ -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"
/**
@@ -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
@@ -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)
}
@@ -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)
@@ -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.
*
* <p>Ported from WinNative's gamescope runtime (GPL-3.0).
*/
@@ -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()
}
+56 -3
View File
@@ -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 <dlfcn.h>
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdlib.h>
#include <sys/statvfs.h>
#include <sys/syscall.h>
#include <unistd.h>
/* 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;