fix: repair Android 36 runtime edge cases

This commit is contained in:
xXJsonDeruloXx committed 2026-10-22 10:46:06 -04:00
commit 42239e0097
25 files changed
+539 -29

No files matched your search

+2
View File
@@ -207,6 +207,8 @@ android {
pickFirsts += ['**/libtermux.so']
// Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
// Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
}
}
+19 -4
View File
@@ -11,6 +11,8 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<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" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -19,7 +21,8 @@
<uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
@@ -40,6 +43,7 @@
android:hardwareAccelerated="true"
android:largeHeap="true"
android:extractNativeLibs="true"
android:requestLegacyExternalStorage="true"
android:appCategory="game"
android:isGame="true">
@@ -190,13 +194,24 @@
<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>
<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>
<service
android:name=".stores.download.StoreDownloadService"
android:exported="false"
@@ -412,6 +412,8 @@ class MainActivity : ComponentActivity() {
private var logsEnabled by mutableStateOf(true)
private var vacDiagnostics by mutableStateOf(false)
private var agentCommands by mutableStateOf(false)
private var bluetoothAllowed by mutableStateOf<Boolean?>(null)
private var allFilesAllowed by mutableStateOf<Boolean?>(null)
private var showRoms by mutableStateOf(false)
private var homeAppSelected by mutableStateOf(false)
private var homeScreenEnabled by mutableStateOf(false)
@@ -790,6 +792,7 @@ class MainActivity : ComponentActivity() {
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
bluetoothAllowed = bluetoothAllowed, allFilesAllowed = allFilesAllowed,
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
@@ -950,6 +953,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()
@@ -1183,15 +1188,28 @@ class MainActivity : ComponentActivity() {
}
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)
}
if (SessionPrefs.micEnabled(this) && !SessionPrefs.micAsked(this)
&& checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.RECORD_AUDIO)
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 {
if (com.droiddeck.launcher.core.AllFilesAccess.ask(this)) SessionPrefs.setAllFilesAsked(this)
}
}, "all-files-access").start()
}
Thread({ com.droiddeck.launcher.stores.StoreAccounts.encryptAll(this) }, "store-credentials").start()
if (!SessionState.running) Thread({
com.droiddeck.launcher.session.LogMigration.run(this)
@@ -1280,11 +1298,32 @@ 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)
requestedOrientation = orientation
refreshWifiDiscovery()
refreshPermissionRows()
com.droiddeck.launcher.stores.StoresState.refresh(this)
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
com.droiddeck.launcher.ui.Motion.refresh(this)
@@ -109,7 +109,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"
+ " --log-level=info";
@@ -158,6 +158,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);
@@ -211,7 +212,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"));
@@ -0,0 +1,46 @@
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): Boolean {
if (granted() || activity.isFinishing) return false
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()
return true
}
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)) }
}
}
}
@@ -67,9 +67,11 @@ public final class Downloader {
String expectedSha256, java.util.function.Consumer<Float> progress) {
Identity identity = loadIdentity(destination, resume, expectedSha256);
for (int i = 0; i < sources.size(); i++) {
if (Thread.currentThread().isInterrupted()) return false;
String source = sources.get(i);
if (i > 0) Log.i(TAG, "retrying " + original + " via " + source);
Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, progress);
if (Thread.currentThread().isInterrupted()) return false;
if (outcome == Outcome.DONE) {
if (expectedSha256 != null && !expectedSha256.equalsIgnoreCase(Hashes.sha256(destination))) {
Log.w(TAG, "checksum mismatch from " + source + "; trying another source");
@@ -212,6 +214,7 @@ public final class Downloader {
byte[] buffer = new byte[1 << 16];
long lastReport = 0;
for (int read = in.read(buffer); read > 0; read = in.read(buffer)) {
if (Thread.currentThread().isInterrupted()) return Outcome.RETRY;
out.write(buffer, 0, read);
written += read;
if (progress != null && written - lastReport > (1 << 20)) {
@@ -5,6 +5,8 @@ import java.io.File
import java.util.function.Consumer
object HostProcess {
const val LINKER = "/system/bin/linker64"
private const val TAG = "HostProcess"
@JvmStatic
@@ -10,9 +10,10 @@ import com.droiddeck.launcher.session.SessionPrefs
object WifiDiscovery {
val permissions = arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
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
@@ -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
@@ -22,7 +23,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)
@@ -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
@@ -26,7 +27,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)
}
@@ -442,9 +442,12 @@ object AddedGames {
}
}
fun listingFile(context: Context): File = File(context.filesDir, "session/added-games.json")
@Synchronized
fun writeListing(context: Context, games: List<Game>,
extra: List<JSONObject> = listOfNotNull(com.droiddeck.launcher.stores.epic.EpicClient.steamListing(context))): 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(',')
@@ -464,7 +467,13 @@ object AddedGames {
if (games.isNotEmpty() || i > 0) json.append(',')
json.append(entry.toString())
}
file.writeText(json.append(']').toString())
val staged = File(file.path + ".tmp")
staged.writeText(json.append(']').toString())
java.nio.file.Files.move(
staged.toPath(), file.toPath(),
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
)
return file
}
@@ -3,7 +3,9 @@ package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import androidx.annotation.VisibleForTesting
import org.json.JSONArray
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
object AddedGamesPublisher {
private const val TAG = "AddedGamesPublisher"
@@ -13,6 +15,7 @@ object AddedGamesPublisher {
internal var now = false
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "added-games-publish") }
private val retries = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "added-games-publish-retry") }
fun later(context: Context, live: () -> Unit = {}) {
val app = context.applicationContext
@@ -23,6 +26,42 @@ 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 known = runCatching {
val listing = JSONArray(before)
List(listing.length()) { listing.getJSONObject(it).optString("exe") }.toSet()
}.getOrDefault(emptySet())
val games = AddedGames.scan(app)
AddedGames.writeListing(app, games)
val arch = com.droiddeck.launcher.session.SessionPrefs.activeSteamClientArch(app)
LibraryCache.save(app, Library.launchableGames(app, added = games, arch = arch), arch == "x86_64")
val fresh = games.filter { it.guestExe !in known }
if (fresh.isEmpty()) return@execute
Log.i(TAG, "session refresh: ${fresh.size} game(s) not in the listing the session started with")
val attempts = java.util.concurrent.atomic.AtomicInteger()
val retry = retries.scheduleWithFixedDelay({
try {
if (com.droiddeck.launcher.stores.SteamLiveShortcuts.sync(app, fresh) { true } ||
!com.droiddeck.launcher.session.SessionState.running || attempts.incrementAndGet() >= 36) {
throw java.util.concurrent.CancellationException()
}
} catch (e: java.util.concurrent.CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "session shortcut retry: ${e.message}")
}
}, 5, 5, TimeUnit.SECONDS)
retries.schedule({ retry.cancel(false) }, 180, TimeUnit.SECONDS)
} catch (e: Exception) {
Log.w(TAG, "session refresh: ${e.message}")
}
}
}
fun republish(context: Context) {
val games = AddedGames.scan(context)
AddedGames.writeListing(context, games)
@@ -22,6 +22,7 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
@@ -69,6 +70,7 @@ public final class LinuxRuntimeInstaller {
volatile Step step = Step.OTHER;
volatile int percent = -1;
volatile boolean ok;
volatile Thread worker;
final boolean removal;
boolean maintenance;
Job(boolean removal, String stage) { this.removal = removal; this.stage = stage; }
@@ -113,6 +115,7 @@ public final class LinuxRuntimeInstaller {
owner = running == null;
if (owner) { running = new Job(false, context.getString(R.string.user_apps_starting)); removalError = null; }
job = running;
if (owner) job.worker = Thread.currentThread();
if (listener != null) job.listeners.add(listener);
}
if (!owner) return join(job, listener);
@@ -148,6 +151,12 @@ public final class LinuxRuntimeInstaller {
synchronized (JOB_LOCK) { return running != null; }
}
public static void cancelActiveOperation() {
synchronized (JOB_LOCK) {
if (running != null && running.worker != null) running.worker.interrupt();
}
}
public static boolean isRemoving() {
synchronized (JOB_LOCK) { return running != null && running.removal; }
}
@@ -275,6 +284,7 @@ public final class LinuxRuntimeInstaller {
keepPartial = true;
return false;
}
requireNotInterrupted();
if (listener != null) listener.onProgress(Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1);
String actual = Hashes.sha256(archive);
@@ -293,6 +303,7 @@ public final class LinuxRuntimeInstaller {
RuntimeFileTree.delete(staging, null);
return false;
}
requireNotInterrupted();
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
if (!discard(old, null)) {
@@ -303,6 +314,11 @@ public final class LinuxRuntimeInstaller {
RuntimeFileTree.delete(staging, null);
return false;
}
if (Thread.currentThread().isInterrupted()) {
if (old.isDirectory()) old.renameTo(root);
RuntimeFileTree.delete(staging, null);
return false;
}
try {
RuntimeFileTree.carryHome(old, staging);
@@ -314,7 +330,7 @@ public final class LinuxRuntimeInstaller {
return false;
}
if (!staging.renameTo(root)) {
if (Thread.currentThread().isInterrupted() || !staging.renameTo(root)) {
File keptTo = new File(staging, USER_DATA);
if (keptTo.isDirectory() && old.isDirectory()) keptTo.renameTo(new File(old, USER_DATA));
if (old.isDirectory()) old.renameTo(root);
@@ -470,6 +486,7 @@ public final class LinuxRuntimeInstaller {
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
TarArchiveEntry entry;
while ((entry = tar.getNextTarEntry()) != null) {
requireNotInterrupted();
File file = ArchivePaths.inside(destination, entry.getName());
if (file == null) {
Log.w(TAG, "skipping entry outside the rootfs: " + entry.getName());
@@ -508,7 +525,10 @@ public final class LinuxRuntimeInstaller {
try (OutputStream out = new FileOutputStream(part)) {
byte[] buffer = new byte[1 << 16];
int read;
while ((read = tar.read(buffer)) != -1) out.write(buffer, 0, read);
while ((read = tar.read(buffer)) != -1) {
requireNotInterrupted();
out.write(buffer, 0, read);
}
}
if ((entry.getMode() & 0111) != 0) part.setExecutable(true, false);
place(part, file);
@@ -538,6 +558,10 @@ public final class LinuxRuntimeInstaller {
}
}
private static void requireNotInterrupted() throws InterruptedIOException {
if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException("Runtime installation was interrupted");
}
static File partFile(File file) {
return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode()));
}
@@ -13,11 +13,21 @@ import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
class RuntimeInstallService : Service() {
@Volatile private var activeWork: Thread? = null
@Volatile private var timedOut = false
override fun attachBaseContext(newBase: android.content.Context) =
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(newBase))
override fun onBind(intent: Intent?): IBinder? = null
override fun onTimeout(startId: Int, fgsType: Int) {
timedOut = true
LinuxRuntimeInstaller.cancelActiveOperation()
activeWork?.interrupt()
stopForeground(STOP_FOREGROUND_DETACH)
stopSelf()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_PACKAGE) return holdPackage(startId)
val removal = intent?.action == ACTION_REMOVE
@@ -27,7 +37,7 @@ class RuntimeInstallService : Service() {
stopSelf(startId)
return START_NOT_STICKY
}
Thread({
activeWork = Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = ""
val progress = LinuxRuntimeInstaller.ProgressListener { stage, percent ->
@@ -38,10 +48,12 @@ class RuntimeInstallService : Service() {
}
if (removal) LinuxRuntimeInstaller.attach(progress)
else LinuxRuntimeInstaller.install(applicationContext, release!!, progress)
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}, "runtime-install").start()
if (!timedOut) {
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}
}, "runtime-install").also { it.start() }
return START_NOT_STICKY
}
@@ -326,6 +326,18 @@ 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) {
prefs(context).edit().putBoolean("bluetoothAsked", true).apply()
}
fun setMicEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("mic", on).apply()
}
@@ -437,12 +437,19 @@ class SessionService : Service() {
.onFailure { Log.w(TAG, "Epic install directory setup failed", it) }
}
if (steamHere || noSteamGame) {
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 (steamHere && 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})" })
}
guest.add("BL_LAUNCH_DIR=" + sessionRoot.path)
GameStorage.effective(this)?.let { guest.add("BL_LIBRARY_LABEL=" + it.label.replace('"', ' ')) }
@@ -19,9 +19,10 @@ object SteamLiveShortcuts {
private const val EVAL_TIMEOUT_MS = 20_000L
private const val THAW_WAIT_MS = 15_000L
fun sync(context: Context, games: List<AddedGames.Game>, include: (AddedGames.Game) -> Boolean = { it.source != Library.ADDED }) {
fun sync(context: Context, games: List<AddedGames.Game>, 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).put("appid", g.appId and 0xFFFFFFFFL))
val js = """
@@ -35,9 +36,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
}
fun ensure(context: Context, name: String, exe: String, dir: String, compat: Boolean, appId: Long? = null): Long? {
@@ -70,6 +70,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)
@@ -100,6 +100,8 @@ class FrontEndState(
val logsEnabled: Boolean,
val vacDiagnostics: Boolean = false,
val agentCommands: Boolean = false,
val bluetoothAllowed: Boolean? = null,
val allFilesAllowed: Boolean? = null,
val steamGames: List<Library.SteamGame>,
val steamGamesX64: Boolean = false,
val emulators: List<Library.Emulator>,
@@ -200,6 +202,8 @@ class FrontEndActions(
val onLogs: () -> Unit,
val onVacDiagnostics: () -> Unit = {},
val onAgentCommands: () -> Unit = {},
val onBluetoothAllowed: () -> Unit = {},
val onAllFilesAllowed: () -> Unit = {},
val onShareLogs: () -> Unit = {},
val onClearLogs: () -> Unit = {},
val onResetSettings: () -> Unit = {},
@@ -451,6 +451,12 @@ internal fun SetupPanel(
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ToggleRow(host, "vacdiag", stringResource(R.string.setup_vac_diag), stringResource(R.string.setup_vac_diag_hint), s.vacDiagnostics) { a.onVacDiagnostics() }
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)
+8
View File
@@ -1792,6 +1792,14 @@
<string name="adbpair_waiting_long">Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification.</string>
<string name="adbpair_code_label">6-digit pairing code</string>
<string name="adbpair_code_hint">Tap Enter code and type the 6 digits shown in Settings.</string>
<string name="setup_all_files">All files access</string>
<string name="setup_all_files_hint">Lets DroidDeck see games and programs on your storage and SD card</string>
<string name="setup_bluetooth">Bluetooth Steam Controller</string>
<string name="setup_bluetooth_hint">Lets DroidDeck find a paired Steam Controller (Nearby devices)</string>
<string name="all_files_title">Allow access to your game folders</string>
<string name="all_files_message">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.</string>
<string name="all_files_allow">Open settings</string>
<string name="all_files_later">Not now</string>
<string name="adbpair_title_code">Enter the Wi-Fi pairing code</string>
<string name="adbpair_title_retry">Try the code again</string>
<string name="adbpair_enter_code">Enter code</string>
@@ -0,0 +1 @@
sdk=35
@@ -119,6 +119,7 @@ if [ -n "${BL_VK_DRIVER:-}" ]; then
unset bl_icd_lib
fi
export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-0}
rm -rf /tmp/.droiddeck-rx
start_agent() {
[ -n "${BL_LAUNCH_DIR:-}" ] && [ -x /usr/local/bin/droiddeck-agent ] || return 0
+143 -2
View File
@@ -3,8 +3,15 @@
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <stdint.h>
#include <sys/auxv.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <sys/statvfs.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <limits.h>
#if defined(__aarch64__) || defined(NOEXEC_TEST)
@@ -36,11 +43,145 @@ __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 const char default_rx_dir[] = "/tmp/.droiddeck-rx";
static const char *rx_directory(void) {
#ifdef NOEXEC_TEST
const char *directory = getenv("DROIDDECK_NOEXEC_CACHE_DIR");
if (directory && *directory) return directory;
#endif
return default_rx_dir;
}
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) {
const char *directory = rx_directory();
size_t directory_len = strlen(directory);
if (!directory_len || directory_len + 40 >= PATH_MAX) return -1;
char path[PATH_MAX], staged[PATH_MAX];
char *end = path + directory_len;
memcpy(path, directory, directory_len);
if (end[-1] != '/') *end++ = '/';
*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, directory, 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);
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;
+120
View File
@@ -70,5 +70,125 @@ class NoexecPreloadTest(unittest.TestCase):
self.assertEqual(refused[1:], ["protect 13", "read -"])
EXECMOD = r"""
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <unistd.h>
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 <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
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):
self.cache = self.tmp / self._testMethodName / "rx"
self.cache.mkdir(parents=True)
def tearDown(self):
shutil.rmtree(self.tmp / self._testMethodName, ignore_errors=True)
def run_with(self, *libs):
env = dict(os.environ, LD_PRELOAD=":".join(str(lib) for lib in libs),
DROIDDECK_NOEXEC_CACHE_DIR=str(self.cache))
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(self.cache)), 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()