diff --git a/app/build.gradle b/app/build.gradle index c889af588..b2a309465 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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'] } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 543994093..14a4613ad 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,8 @@ + + @@ -19,7 +21,8 @@ - + + @@ -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 @@ + android:foregroundServiceType="specialUse" + android:stopWithTask="false"> + + + android:exported="false" + android:foregroundServiceType="dataSync" /> + android:exported="false" + android:foregroundServiceType="specialUse"> + + (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) @@ -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() - 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) 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 849455b7a..aaabc8bf0 100644 --- a/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java +++ b/app/src/main/java/com/droiddeck/launcher/audio/PulseAudioComponent.java @@ -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 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")); 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..c4fd08d0a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/AllFilesAccess.kt @@ -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)) } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java index 183076188..35e62be1a 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java +++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java @@ -67,9 +67,11 @@ public final class Downloader { String expectedSha256, java.util.function.Consumer 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)) { 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 4a8b12934..d629395cb 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/HostProcess.kt @@ -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 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 66cc77f91..2e6125dab 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/WifiDiscovery.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/WifiDiscovery.kt @@ -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 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 27cb9dbce..d7e0af848 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 @@ -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) 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 6fd89fa99..b6c1e1304 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 @@ -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) } 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 5de835f6e..5cf0c326f 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -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, extra: List = 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 } 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 c70b2a895..52eff3879 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGamesPublisher.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGamesPublisher.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index d6f6b0076..e16a11870 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -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())); } 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 d2cee0435..5b0263e5f 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt @@ -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 } 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 cd0a750ac..d20f410f4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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() } 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 fd9d77270..b8415e600 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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('"', ' ')) } 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 345f4da32..2da56ecfb 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt @@ -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, 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).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? { 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 a32bceeb6..1dc3daf23 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 @@ -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) 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 1840d0f07..1a2fc56df 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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, val steamGamesX64: Boolean = false, val emulators: List, @@ -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 = {}, 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 b5bbaa2e9..17983d708 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a41437db..8cfd65e8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1792,6 +1792,14 @@ Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification. 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 Enter code 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 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 39ee3c8a6..019bc5042 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -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 diff --git a/tools/linuxfs/preload/noexec.c b/tools/linuxfs/preload/noexec.c index 0b0326bda..ef7c64b8b 100644 --- a/tools/linuxfs/preload/noexec.c +++ b/tools/linuxfs/preload/noexec.c @@ -3,8 +3,15 @@ #include #include #include +#include +#include +#include +#include +#include #include +#include #include +#include #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; diff --git a/tools/tests/test_noexec_preload.py b/tools/tests/test_noexec_preload.py index 0e563ec49..a2a6b13f9 100644 --- a/tools/tests/test_noexec_preload.py +++ b/tools/tests/test_noexec_preload.py @@ -70,5 +70,125 @@ 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): + 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()