diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e28725..c1314df 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -162,11 +162,13 @@ dependencies { androidTestImplementation("androidx.test:runner:1.6.2") } -val verifyGfxstreamRuntimeAssets by +val verifyGraphicsRuntimeAssets by tasks.registering { val runtimeRoot = file("src/main/assets/runtime/arm64-v8a") inputs.dir(runtimeRoot.resolve("gfxstream-host")) inputs.dir(runtimeRoot.resolve("gfxstream-guest")) + inputs.dir(runtimeRoot.resolve("virgl-host")) + inputs.dir(runtimeRoot.resolve("virgl-angle-vulkan")) doLast { fun ByteArray.containsSequence(needle: ByteArray): Boolean { @@ -184,7 +186,13 @@ val verifyGfxstreamRuntimeAssets by return false } - val bundleNames = listOf("gfxstream-host", "gfxstream-guest") + val bundleNames = + listOf( + "gfxstream-host", + "gfxstream-guest", + "virgl-host", + "virgl-angle-vulkan", + ) val manifests = bundleNames.associateWith { bundleName -> val bundle = runtimeRoot.resolve(bundleName) @@ -238,5 +246,5 @@ val verifyGfxstreamRuntimeAssets by } tasks.matching { it.name == "preBuild" }.configureEach { - dependsOn(verifyGfxstreamRuntimeAssets) + dependsOn(verifyGraphicsRuntimeAssets) } diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/MANIFEST.properties b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/MANIFEST.properties new file mode 100644 index 0000000..3bcb392 --- /dev/null +++ b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/MANIFEST.properties @@ -0,0 +1,12 @@ +format=1 +runtime=2.1.24923-f09a19ce-2 +abi=arm64-v8a +source_package=angle-android +source_backend=vulkan +termux_packages_commit=4c3e1c0c700f7515d89ecbb9f636d5289f6cd48e +source_deb_sha256=3e421cecefc8cb5ca9c57ffe1eb38f77dc315d88d99f1018ef0fdd2add6ca330 +lib/libEGL_angle.so.sha256=902da0dabfdc87ed572b7c597e9007066f871e9674ba2ff7c1c73c51d6066a99 +lib/libGLESv1_CM_angle.so.sha256=8495dcb6af2d5743283c783a58e744ed160d868e1baf6322aa7ee71b006cbe65 +lib/libGLESv2_angle.so.sha256=e99093dd5de9e05d40a4d427dcbd3803e5781e579e9d65543db71c0a3fa1ffba +lib/libfeature_support_angle.so.sha256=afa4ffcda88fca52007ed8a40bfd9bfd27db34f465c753b1d4c4e0540ca7b252 +share/doc/copyright.sha256=bf4da21bd20bcfb5b60b7ecc67fa864a79be049e21d6178076887f178dd6c71a diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libEGL_angle.so b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libEGL_angle.so new file mode 100755 index 0000000..1eb8dac Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libEGL_angle.so differ diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libGLESv1_CM_angle.so b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libGLESv1_CM_angle.so new file mode 100755 index 0000000..d028a01 Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libGLESv1_CM_angle.so differ diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libGLESv2_angle.so b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libGLESv2_angle.so new file mode 100755 index 0000000..26feee0 Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libGLESv2_angle.so differ diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libfeature_support_angle.so b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libfeature_support_angle.so new file mode 100755 index 0000000..a9c7b8c Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/lib/libfeature_support_angle.so differ diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/share/doc/copyright b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/share/doc/copyright new file mode 100644 index 0000000..0f65fd6 --- /dev/null +++ b/app/src/main/assets/runtime/arm64-v8a/virgl-angle-vulkan/share/doc/copyright @@ -0,0 +1,32 @@ +// Copyright 2018 The ANGLE Project Authors. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// +// Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// Neither the name of TransGaming Inc., Google Inc., 3DLabs Inc. +// Ltd., nor the names of their contributors may be used to endorse +// or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +// COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +// BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +// LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +// ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-host/MANIFEST.properties b/app/src/main/assets/runtime/arm64-v8a/virgl-host/MANIFEST.properties new file mode 100644 index 0000000..1afb723 --- /dev/null +++ b/app/src/main/assets/runtime/arm64-v8a/virgl-host/MANIFEST.properties @@ -0,0 +1,12 @@ +format=1 +runtime=1.3.0-1 +abi=arm64-v8a +source_package=virglrenderer-android +termux_packages_commit=4bf6c61f492c1b8248192321e0f4440f4decb49f +source_deb_sha256=73b34df68364100af063f1fd6cdeef90d8992f921d576b8262b35619c8223eab +bin/virgl_test_server_android.sha256=c4fa02e432f2edbf974c8bf85922b6d1f7418be633bce443e5b1893c312273aa +lib/libepoxy.so.sha256=7b50fe1ef66cfea354f398523169bdb4ac8ec3b2715ac77073540bd5a1b3075f +lib/libvirglrenderer.so.sha256=d013d65a4f985233f75998945d6ca7af114dc2460f08969811c03ec09bf2d20a +share/doc/COPYING-gl4es.sha256=69a3049c77abd88658f6ad7cf6a5b6ceb5cf03b80e60f7c4657d90322cfab7bd +share/doc/COPYING-libepoxy.sha256=8d5144666f9c4df9bbd69b8900086d5979259152a1060421cdcc0fb9061a1c12 +share/doc/COPYING-virglrenderer.sha256=3b32caf16e2402c2aff2b716858713f3e1feded85c25e71bf829816ee5009402 diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-host/bin/virgl_test_server_android b/app/src/main/assets/runtime/arm64-v8a/virgl-host/bin/virgl_test_server_android new file mode 100755 index 0000000..c8f4aac Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/virgl-host/bin/virgl_test_server_android differ diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-host/lib/libepoxy.so b/app/src/main/assets/runtime/arm64-v8a/virgl-host/lib/libepoxy.so new file mode 100755 index 0000000..856fceb Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/virgl-host/lib/libepoxy.so differ diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-host/lib/libvirglrenderer.so b/app/src/main/assets/runtime/arm64-v8a/virgl-host/lib/libvirglrenderer.so new file mode 100755 index 0000000..4afc6f9 Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/virgl-host/lib/libvirglrenderer.so differ diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-host/share/doc/COPYING-gl4es b/app/src/main/assets/runtime/arm64-v8a/virgl-host/share/doc/COPYING-gl4es new file mode 100644 index 0000000..f34587f --- /dev/null +++ b/app/src/main/assets/runtime/arm64-v8a/virgl-host/share/doc/COPYING-gl4es @@ -0,0 +1,20 @@ +Copyright (c) 2016-2018 Sebastien Chevalier +Copyright (c) 2013-2016 Ryan Hileman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-host/share/doc/COPYING-libepoxy b/app/src/main/assets/runtime/arm64-v8a/virgl-host/share/doc/COPYING-libepoxy new file mode 100644 index 0000000..8dd167c --- /dev/null +++ b/app/src/main/assets/runtime/arm64-v8a/virgl-host/share/doc/COPYING-libepoxy @@ -0,0 +1,50 @@ +The libepoxy project code is covered by the MIT license: + +/* + * Copyright © 2013-2014 Intel Corporation + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +The generated code is derived from Khronos's xml files, which appear +under the following license: + +/* + * Copyright (c) 2013 The Khronos Group Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and/or associated documentation files (the + * "Materials"), to deal in the Materials without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Materials, and to + * permit persons to whom the Materials are furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Materials. + * + * THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS. + */ diff --git a/app/src/main/assets/runtime/arm64-v8a/virgl-host/share/doc/COPYING-virglrenderer b/app/src/main/assets/runtime/arm64-v8a/virgl-host/share/doc/COPYING-virglrenderer new file mode 100644 index 0000000..92e1279 --- /dev/null +++ b/app/src/main/assets/runtime/arm64-v8a/virgl-host/share/doc/COPYING-virglrenderer @@ -0,0 +1,20 @@ +virglrenderer is under MIT license and derived from mesa in many parts. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + diff --git a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt index 0f565cf..822d848 100644 --- a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt +++ b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt @@ -60,6 +60,7 @@ import org.randomcoder.udroid.runtime.DesktopEnvironment import org.randomcoder.udroid.runtime.DesktopEnvironmentScanner import org.randomcoder.udroid.runtime.DesktopSessionPhase import org.randomcoder.udroid.runtime.InstalledRootfs +import org.randomcoder.udroid.runtime.ProotEnvironmentProfileStore import org.randomcoder.udroid.runtime.ProotMountProfile import org.randomcoder.udroid.runtime.ProotMountProfileValidator import org.randomcoder.udroid.runtime.RuntimePhase @@ -846,6 +847,9 @@ class MainActivity : ComponentActivity() { ?.let { cleanupWarnings += it.message ?: "launcher shortcuts" } if (resetWork == null) { + runCatching { ProotEnvironmentProfileStore(this@MainActivity).remove(rootfsName) } + .exceptionOrNull() + ?.let { cleanupWarnings += it.message ?: "environment profile" } runCatching { app.mountProfiles.remove(rootfsName) } .exceptionOrNull() ?.let { cleanupWarnings += it.message ?: "mount profile" } diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/DesktopEnvironment.kt b/app/src/main/java/org/randomcoder/udroid/runtime/DesktopEnvironment.kt index 9d225fb..6188cbd 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/DesktopEnvironment.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/DesktopEnvironment.kt @@ -37,6 +37,10 @@ enum class DesktopGraphicsProfile( val storageValue: String, ) { STANDARD("standard"), + SOFTWARE("software"), + ZINK("zink"), + VIRGL("virgl"), + VIRGL_ANGLE("virgl-angle"), GFXSTREAM_EXPERIMENTAL("gfxstream-experimental"), ; @@ -144,6 +148,11 @@ class DesktopConfigurationStore(context: Context) { return configuration } + fun loadGraphicsProfile(rootfsName: String): DesktopGraphicsProfile = + DesktopGraphicsProfile.fromStorage( + preferences.getString(key(rootfsName, KEY_GRAPHICS_PROFILE), null), + ) + fun remove(rootfsName: String) { val prefix = "$rootfsName:" val editor = preferences.edit() diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt index a497b30..1a84618 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt @@ -39,6 +39,10 @@ object ProotApplicationLaunchBuilder { audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, ), ) + val environmentProfile = ProotEnvironmentProfileStore(context).load(rootfs.name) + val guestEnvironment = ProotEnvironmentResolver.resolve(environmentProfile) + val managedEnvironment = + ProotEnvironmentResolver.resolveManagedOverrides(environmentProfile) val prootArguments = buildArguments( prootPath = runtime.executable.absolutePath, @@ -51,6 +55,8 @@ object ProotApplicationLaunchBuilder { audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, launchProfile = launchProfile, mounts = mounts, + guestEnvironment = guestEnvironment, + managedEnvironment = managedEnvironment, ) val temporaryDirectory = File(context.cacheDir, "proot").apply { @@ -90,6 +96,8 @@ object ProotApplicationLaunchBuilder { launchProfile: ProotLaunchProfile? = null, mounts: List = ProotMountResolver.defaults(x11SocketDirectory, audioAuthDirectory), + guestEnvironment: List = ProotEnvironmentResolver.resolve(), + managedEnvironment: List = emptyList(), ): List { require(applicationArguments.isNotEmpty()) return buildList { @@ -107,8 +115,7 @@ object ProotApplicationLaunchBuilder { add("USER=root") add("LOGNAME=root") add("SHELL=/bin/sh") - add("LANG=C.UTF-8") - add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") + addAll(guestEnvironment) add("DISPLAY=:0") if (audioAuthDirectory != null) { add("PULSE_SERVER=${AudioEndpoint.GUEST_SERVER}") @@ -117,7 +124,12 @@ object ProotApplicationLaunchBuilder { add("XDG_CURRENT_DESKTOP=UDROID") add("GDK_BACKEND=x11") add("QT_QPA_PLATFORM=xcb") - addAll(launchProfile?.wrapGuestCommand(applicationArguments) ?: applicationArguments) + val overriddenCommand = + ProotEnvironmentResolver.applyManagedOverrides( + applicationArguments, + managedEnvironment, + ) + addAll(launchProfile?.wrapGuestCommand(overriddenCommand) ?: overriddenCommand) } } diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt index 4124caf..35955e4 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt @@ -31,6 +31,10 @@ object ProotDesktopLaunchBuilder { audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, ), ) + val environmentProfile = ProotEnvironmentProfileStore(context).load(rootfs.name) + val guestEnvironment = ProotEnvironmentResolver.resolve(environmentProfile) + val managedEnvironment = + ProotEnvironmentResolver.resolveManagedOverrides(environmentProfile) val arguments = buildArguments( prootPath = runtime.executable.absolutePath, @@ -42,6 +46,8 @@ object ProotDesktopLaunchBuilder { audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, launchProfile = launchProfile, mounts = mounts, + guestEnvironment = guestEnvironment, + managedEnvironment = managedEnvironment, hasDbusRunSession = File(rootfs, "usr/bin/dbus-run-session").isFile || File(rootfs, "bin/dbus-run-session").isFile, @@ -85,6 +91,8 @@ object ProotDesktopLaunchBuilder { launchProfile: ProotLaunchProfile? = null, mounts: List = ProotMountResolver.defaults(x11SocketDirectory, audioAuthDirectory), + guestEnvironment: List = ProotEnvironmentResolver.resolve(), + managedEnvironment: List = emptyList(), ): List = buildList { add(prootPath) @@ -101,8 +109,7 @@ object ProotDesktopLaunchBuilder { add("USER=root") add("LOGNAME=root") add("SHELL=/bin/sh") - add("LANG=C.UTF-8") - add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") + addAll(guestEnvironment) add("DISPLAY=:0") if (audioAuthDirectory != null) { add("PULSE_SERVER=${AudioEndpoint.GUEST_SERVER}") @@ -130,7 +137,9 @@ object ProotDesktopLaunchBuilder { add("udroid-desktop") addAll(environment.command) } - addAll(launchProfile?.wrapGuestCommand(desktopCommand) ?: desktopCommand) + val overriddenCommand = + ProotEnvironmentResolver.applyManagedOverrides(desktopCommand, managedEnvironment) + addAll(launchProfile?.wrapGuestCommand(overriddenCommand) ?: overriddenCommand) } internal fun compositorScript( diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotEnvironmentProfiles.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotEnvironmentProfiles.kt new file mode 100644 index 0000000..de70f01 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotEnvironmentProfiles.kt @@ -0,0 +1,294 @@ +package org.randomcoder.udroid.runtime + +import android.content.Context +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.io.File +import java.io.FileOutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.UUID + +data class ProotManagedEnvironmentVariable( + val name: String, + val defaultValue: String, +) + +val PROOT_DEFAULT_ENVIRONMENT_VARIABLES = + listOf( + ProotManagedEnvironmentVariable("LANG", "C.UTF-8"), + ProotManagedEnvironmentVariable( + "PATH", + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + ), + ProotManagedEnvironmentVariable("TERM", "xterm-256color"), + ProotManagedEnvironmentVariable("COLORTERM", "truecolor"), + ) + +val PROOT_LOCKED_ENVIRONMENT_VARIABLE_NAMES = + setOf( + "HOME", + "USER", + "LOGNAME", + "SHELL", + "DISPLAY", + "PULSE_SERVER", + "PULSE_COOKIE", + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "DESKTOP_SESSION", + "GDK_BACKEND", + "QT_QPA_PLATFORM", + "GDK_SCALE", + "QT_SCALE_FACTOR", + "XCURSOR_SIZE", + "LIBGL_ALWAYS_SOFTWARE", + "GALLIUM_DRIVER", + "MESA_LOADER_DRIVER_OVERRIDE", + "LIBGL_KOPPER_DRI2", + ) + +data class ProotCustomEnvironmentVariable( + val id: String = UUID.randomUUID().toString(), + val name: String, + val value: String, +) + +data class ProotEnvironmentProfile( + val defaultOverrides: Map = emptyMap(), + val managedOverrides: Map = emptyMap(), + val customVariables: List = emptyList(), +) + +object ProotEnvironmentProfileValidator { + fun requireValid(profile: ProotEnvironmentProfile): ProotEnvironmentProfile { + val defaultNames = PROOT_DEFAULT_ENVIRONMENT_VARIABLES.mapTo(mutableSetOf()) { it.name } + require(profile.defaultOverrides.keys.all(defaultNames::contains)) { + "The profile contains an unknown default environment variable" + } + profile.defaultOverrides.values.forEach(::requireValidValue) + require(profile.managedOverrides.keys.all(PROOT_LOCKED_ENVIRONMENT_VARIABLE_NAMES::contains)) { + "The profile contains an unknown uDroid-managed environment variable" + } + profile.managedOverrides.values.forEach(::requireValidValue) + + require(profile.customVariables.size <= MAX_CUSTOM_VARIABLES) { + "An environment profile supports at most $MAX_CUSTOM_VARIABLES custom variables" + } + require(profile.customVariables.map { it.id }.distinct().size == profile.customVariables.size) { + "Custom environment variable IDs must be unique" + } + require(profile.customVariables.map { it.name }.distinct().size == profile.customVariables.size) { + "Custom environment variable names must be unique" + } + profile.customVariables.forEach { variable -> + require(SAFE_ID.matches(variable.id)) { "Invalid custom environment variable ID" } + require(variable.name.length <= MAX_NAME_LENGTH && VARIABLE_NAME.matches(variable.name)) { + "Invalid environment variable name" + } + require(variable.name !in defaultNames) { + "Custom variables cannot replace editable defaults" + } + require(variable.name !in PROOT_LOCKED_ENVIRONMENT_VARIABLE_NAMES) { + "${variable.name} is managed by uDroid" + } + requireValidValue(variable.value) + } + return profile + } + + private fun requireValidValue(value: String) { + require(value.length <= MAX_VALUE_LENGTH) { "Environment variable value is too long" } + require('\u0000' !in value) { "Environment variable value contains NUL" } + } + + private const val MAX_CUSTOM_VARIABLES = 64 + private const val MAX_NAME_LENGTH = 128 + private const val MAX_VALUE_LENGTH = 8192 + private val VARIABLE_NAME = Regex("[A-Za-z_][A-Za-z0-9_]*") + private val SAFE_ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,95}") +} + +object ProotEnvironmentResolver { + fun resolve(profile: ProotEnvironmentProfile = ProotEnvironmentProfile()): List { + ProotEnvironmentProfileValidator.requireValid(profile) + return buildList { + PROOT_DEFAULT_ENVIRONMENT_VARIABLES.forEach { variable -> + add("${variable.name}=${profile.defaultOverrides[variable.name] ?: variable.defaultValue}") + } + profile.customVariables.forEach { add("${it.name}=${it.value}") } + } + } + + fun resolveManagedOverrides( + profile: ProotEnvironmentProfile = ProotEnvironmentProfile(), + ): List { + ProotEnvironmentProfileValidator.requireValid(profile) + return PROOT_LOCKED_ENVIRONMENT_VARIABLE_NAMES.mapNotNull { name -> + profile.managedOverrides[name]?.let { value -> "$name=$value" } + } + } + + fun applyManagedOverrides( + command: List, + overrides: List, + ): List = + if (overrides.isEmpty()) command else listOf("/usr/bin/env") + overrides + command +} + +class ProotEnvironmentProfileStore(context: Context) { + private val systemsDirectory = File(context.applicationContext.filesDir, "linux-systems") + + @Synchronized + fun load(systemId: String): ProotEnvironmentProfile { + val file = profileFile(systemId) + if (!file.isFile) return ProotEnvironmentProfile() + return ProotEnvironmentProfileCodec.decode(file.readText()) + } + + @Synchronized + fun save( + systemId: String, + profile: ProotEnvironmentProfile, + ): ProotEnvironmentProfile { + requireSafeSystemId(systemId) + val validated = ProotEnvironmentProfileValidator.requireValid(profile) + val encoded = ProotEnvironmentProfileCodec.encode(validated) + val target = profileFile(systemId) + check(target.parentFile?.mkdirs() == true || target.parentFile?.isDirectory == true) { + "Could not create environment profile storage for $systemId" + } + val temporary = File(target.parentFile, "${target.name}.tmp") + FileOutputStream(temporary).use { output -> + output.write(encoded.toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + try { + Files.move( + temporary.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + temporary.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + return validated + } + + @Synchronized + fun restoreDefaults(systemId: String): ProotEnvironmentProfile = + save(systemId, ProotEnvironmentProfile()) + + @Synchronized + fun remove(systemId: String) { + val file = profileFile(systemId) + if (file.exists()) check(file.delete()) { "Could not delete environment profile for $systemId" } + file.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete() + } + + private fun profileFile(systemId: String): File { + requireSafeSystemId(systemId) + return File(File(systemsDirectory, systemId), PROFILE_FILE_NAME) + } + + private fun requireSafeSystemId(systemId: String) { + require(SAFE_SYSTEM_ID.matches(systemId)) { "Unsafe Linux system ID: $systemId" } + } + + private companion object { + const val PROFILE_FILE_NAME = "environment.json" + val SAFE_SYSTEM_ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,95}") + } +} + +internal object ProotEnvironmentProfileCodec { + fun encode(profile: ProotEnvironmentProfile): String { + ProotEnvironmentProfileValidator.requireValid(profile) + val encoded = + buildJsonObject { + put("format", FORMAT) + put("defaults_revision", DEFAULTS_REVISION) + put( + "default_overrides", + JsonObject(profile.defaultOverrides.mapValues { JsonPrimitive(it.value) }), + ) + put( + "managed_overrides", + JsonObject(profile.managedOverrides.mapValues { JsonPrimitive(it.value) }), + ) + put( + "custom_variables", + JsonArray( + profile.customVariables.map { variable -> + buildJsonObject { + put("id", variable.id) + put("name", variable.name) + put("value", variable.value) + } + }, + ), + ) + }.toString() + require(encoded.toByteArray(Charsets.UTF_8).size <= MAX_ENCODED_BYTES) { + "Environment profile is too large" + } + return encoded + } + + fun decode(encoded: String): ProotEnvironmentProfile { + require(encoded.toByteArray(Charsets.UTF_8).size <= MAX_ENCODED_BYTES) { + "Environment profile is too large" + } + val value = Json.parseToJsonElement(encoded).jsonObject + require(value.requiredString("format") == FORMAT) { + "Unsupported environment profile format" + } + val overrides = + value["default_overrides"] + ?.jsonObject + ?.mapValues { (_, entry) -> entry.jsonPrimitive.content } + .orEmpty() + val managedOverrides = + value["managed_overrides"] + ?.jsonObject + ?.mapValues { (_, entry) -> entry.jsonPrimitive.content } + .orEmpty() + val customVariables = + value["custom_variables"] + ?.jsonArray + ?.map { element -> + val variable = element.jsonObject + ProotCustomEnvironmentVariable( + id = variable.requiredString("id"), + name = variable.requiredString("name"), + value = variable.requiredString("value"), + ) + }.orEmpty() + return ProotEnvironmentProfileValidator.requireValid( + ProotEnvironmentProfile( + defaultOverrides = overrides, + managedOverrides = managedOverrides, + customVariables = customVariables, + ), + ) + } + + private fun JsonObject.requiredString(key: String): String = getValue(key).jsonPrimitive.content + + private const val FORMAT = "1" + private const val DEFAULTS_REVISION = 1 + private const val MAX_ENCODED_BYTES = 128 * 1024 +} diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotLaunchProfile.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotLaunchProfile.kt index 82c2267..5e6a6b9 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotLaunchProfile.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotLaunchProfile.kt @@ -6,3 +6,34 @@ interface ProotLaunchProfile { fun wrapGuestCommand(command: List): List } + +internal class EnvironmentProotLaunchProfile private constructor( + private val environment: List, +) : ProotLaunchProfile { + override fun addBindings(arguments: MutableList) = Unit + + override fun wrapGuestCommand(command: List): List = + listOf("/usr/bin/env") + environment + command + + companion object { + fun from(profile: DesktopGraphicsProfile): ProotLaunchProfile? = + when (profile) { + DesktopGraphicsProfile.STANDARD -> null + DesktopGraphicsProfile.SOFTWARE -> + EnvironmentProotLaunchProfile( + listOf("LIBGL_ALWAYS_SOFTWARE=1", "GALLIUM_DRIVER=llvmpipe"), + ) + DesktopGraphicsProfile.ZINK -> + EnvironmentProotLaunchProfile( + listOf( + "MESA_LOADER_DRIVER_OVERRIDE=zink", + "GALLIUM_DRIVER=zink", + "LIBGL_KOPPER_DRI2=true", + ), + ) + DesktopGraphicsProfile.VIRGL -> null + DesktopGraphicsProfile.VIRGL_ANGLE -> null + DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL -> null + } + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt index ec0c8cf..ec3e01d 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt @@ -30,6 +30,7 @@ object ProotTerminalLaunchBuilder { rootfs: File = InstalledRootfsResolver.resolve(context), x11SocketDirectory: File? = null, audioEndpoint: AudioEndpoint? = null, + launchProfile: ProotLaunchProfile? = null, ): ProotTerminalLaunch { require(File(rootfs, RootfsInstallationPipeline.READY_MARKER).isFile) { "The selected Linux image is not ready" @@ -59,6 +60,10 @@ object ProotTerminalLaunchBuilder { audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, ), ) + val environmentProfile = ProotEnvironmentProfileStore(context).load(rootfs.name) + val guestEnvironment = ProotEnvironmentResolver.resolve(environmentProfile) + val managedEnvironment = + ProotEnvironmentResolver.resolveManagedOverrides(environmentProfile) val arguments = buildArguments( @@ -69,7 +74,10 @@ object ProotTerminalLaunchBuilder { guestShell = guestShell, x11SocketDirectory = x11SocketDirectory?.absolutePath, audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, + launchProfile = launchProfile, mounts = mounts, + guestEnvironment = guestEnvironment, + managedEnvironment = managedEnvironment, ) val environment = buildEnvironment( @@ -96,8 +104,11 @@ object ProotTerminalLaunchBuilder { guestShell: String, x11SocketDirectory: String? = null, audioAuthDirectory: String? = null, + launchProfile: ProotLaunchProfile? = null, mounts: List = ProotMountResolver.defaults(x11SocketDirectory, audioAuthDirectory), + guestEnvironment: List = ProotEnvironmentResolver.resolve(), + managedEnvironment: List = emptyList(), ): Array = buildList { // TerminalSession passes this complete vector to execvp(), including argv[0]. @@ -108,6 +119,7 @@ object ProotTerminalLaunchBuilder { add("--root-id") add("--rootfs=$rootfsPath") addProotBindMounts(mounts) + launchProfile?.addBindings(this) add("--cwd=$guestHome") add("/usr/bin/env") add("-i") @@ -115,17 +127,20 @@ object ProotTerminalLaunchBuilder { add("USER=root") add("LOGNAME=root") add("SHELL=$guestShell") - add("TERM=xterm-256color") - add("COLORTERM=truecolor") - add("LANG=C.UTF-8") - add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") + addAll(guestEnvironment) if (x11SocketDirectory != null) add("DISPLAY=:0") if (audioAuthDirectory != null) { add("PULSE_SERVER=${AudioEndpoint.GUEST_SERVER}") add("PULSE_COOKIE=${AudioEndpoint.GUEST_AUTH_DIRECTORY}/${AudioEndpoint.COOKIE_NAME}") } - add(guestShell) - if (guestShell.endsWith("bash")) add("--login") + val shellCommand = + buildList { + add(guestShell) + if (guestShell.endsWith("bash")) add("--login") + } + val overriddenCommand = + ProotEnvironmentResolver.applyManagedOverrides(shellCommand, managedEnvironment) + addAll(launchProfile?.wrapGuestCommand(overriddenCommand) ?: overriddenCommand) }.toTypedArray() internal fun buildEnvironment( diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt index 743137c..4e45732 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt @@ -40,6 +40,10 @@ import org.randomcoder.udroid.gfxstream.GfxstreamHostSnapshot import org.randomcoder.udroid.gfxstream.GfxstreamProotLaunchProfile import org.randomcoder.udroid.install.ProotRuntimeInstaller import org.randomcoder.udroid.linuxapps.LinuxApplication +import org.randomcoder.udroid.virgl.VirglHostController +import org.randomcoder.udroid.virgl.VirglHostBackend +import org.randomcoder.udroid.virgl.VirglHostSnapshot +import org.randomcoder.udroid.virgl.VirglProotLaunchProfile import org.randomcoder.udroid.x11.X11ServerController import java.io.BufferedReader import java.io.File @@ -57,6 +61,9 @@ class RuntimeSupervisorService : Service() { private val ownedSession = AtomicReference(null) private val ownedDesktop = AtomicReference(null) private val ownedGfxstreamHost = AtomicReference(null) + private val ownedVirglHosts = ConcurrentHashMap() + private val virglApplicationBackends = ConcurrentHashMap() + private val virglHostLock = Any() private val desktopLaunchToken = AtomicReference(null) private val pendingDesktopRestart = AtomicReference(null) private val attachedViews = CopyOnWriteArraySet() @@ -64,6 +71,7 @@ class RuntimeSupervisorService : Service() { private val applicationExecutor = Executors.newCachedThreadPool() private val x11Controller by lazy { X11ServerController(this, app.journal) } private val audioConfigurationStore by lazy { AudioConfigurationStore(this) } + private val desktopConfigurationStore by lazy { DesktopConfigurationStore(this) } private val audioController by lazy { AudioServerController(this, app.journal, applicationExecutor) } @@ -247,6 +255,7 @@ class RuntimeSupervisorService : Service() { desktopLaunchToken.set(null) ownedDesktop.getAndSet(null)?.let { terminateDesktopProcess(it, OsConstants.SIGKILL) } closeGfxstreamHost() + closeVirglHost() stopApplicationProcesses() audioController.stop(app.runtimeState.current().bootId) applicationExecutor.shutdownNow() @@ -453,6 +462,8 @@ class RuntimeSupervisorService : Service() { val result = runCatching { val rootfs = InstalledRootfsResolver.resolve(this, rootfsName) + val profile = desktopConfigurationStore.loadGraphicsProfile(rootfsName) + val launchProfile = graphicsLaunchProfile(profile) val launch = ProotApplicationLaunchBuilder.create( context = this, @@ -461,6 +472,7 @@ class RuntimeSupervisorService : Service() { x11SocketDirectory = socketDirectory, application = application, audioEndpoint = audioController.endpoint(), + launchProfile = launchProfile, ) val process = ProcessBuilder(launch.command) @@ -471,6 +483,12 @@ class RuntimeSupervisorService : Service() { environment().putAll(launch.environment) }.start() applicationProcesses.put(application.id, process)?.destroy() + val virglBackend = VirglHostBackend.from(profile) + if (virglBackend != null) { + virglApplicationBackends[application.id] = virglBackend + } else { + virglApplicationBackends -= application.id + } drainApplicationOutput(application, process) app.journal.append( component = "linux-app", @@ -596,7 +614,15 @@ class RuntimeSupervisorService : Service() { val rootfs = InstalledRootfsResolver.resolve(this, request.rootfsName) val launchProfile = when (request.configuration.graphicsProfile) { - DesktopGraphicsProfile.STANDARD -> null + DesktopGraphicsProfile.STANDARD, + DesktopGraphicsProfile.SOFTWARE, + DesktopGraphicsProfile.ZINK, + -> EnvironmentProotLaunchProfile.from( + request.configuration.graphicsProfile, + ) + DesktopGraphicsProfile.VIRGL, + DesktopGraphicsProfile.VIRGL_ANGLE, + -> virglLaunchProfile(request.configuration.graphicsProfile) DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL -> { lateinit var host: GfxstreamHostController host = @@ -661,6 +687,7 @@ class RuntimeSupervisorService : Service() { rootfsName = request.rootfsName, environment = request.environment, gfxstreamHost = startingGfxstreamHost, + graphicsProfile = request.configuration.graphicsProfile, mounts = launch.mounts, ) check(ownedDesktop.compareAndSet(null, owned)) { @@ -873,6 +900,79 @@ class RuntimeSupervisorService : Service() { ownedGfxstreamHost.getAndSet(null)?.close() } + private fun virglLaunchProfile(profile: DesktopGraphicsProfile): VirglProotLaunchProfile = + synchronized(virglHostLock) { + val backend = checkNotNull(VirglHostBackend.from(profile)) + val existing = ownedVirglHosts[backend] + existing?.currentSocket()?.let(::VirglProotLaunchProfile) + ?: run { + if (existing != null) { + ownedVirglHosts.remove(backend, existing) + existing.close() + } + lateinit var host: VirglHostController + host = VirglHostController(this, backend) { failure -> + handleVirglHostExit(backend, host, failure) + } + ownedVirglHosts[backend] = host + try { + VirglProotLaunchProfile(host.start()) + } catch (error: Throwable) { + ownedVirglHosts.remove(backend, host) + host.close() + throw error + } + } + } + + private fun graphicsLaunchProfile(profile: DesktopGraphicsProfile): ProotLaunchProfile? = + when (profile) { + DesktopGraphicsProfile.VIRGL, + DesktopGraphicsProfile.VIRGL_ANGLE, + -> virglLaunchProfile(profile) + DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL -> null + else -> EnvironmentProotLaunchProfile.from(profile) + } + + private fun closeVirglHost() { + synchronized(virglHostLock) { + ownedVirglHosts.values.forEach(VirglHostController::close) + ownedVirglHosts.clear() + } + } + + private fun handleVirglHostExit( + backend: VirglHostBackend, + host: VirglHostController, + failure: VirglHostSnapshot, + ) { + if (ownedVirglHosts[backend] !== host) return + app.journal.append( + component = "virgl", + severity = "error", + event = "host_process_lost", + message = failure.detail, + bootId = app.runtimeState.current().bootId, + ) + mainHandler.post { + if (!ownedVirglHosts.remove(backend, host)) return@post + ownedDesktop.get()?.takeIf { + VirglHostBackend.from(it.graphicsProfile) == backend + }?.let { + stopDesktopProcess(restarting = false) + } + virglApplicationBackends.entries + .filter { it.value == backend } + .map { it.key } + .forEach { applicationId -> + if (virglApplicationBackends.remove(applicationId, backend)) { + applicationProcesses.remove(applicationId)?.destroy() + } + } + host.close() + } + } + private fun handleGfxstreamHostExit( host: GfxstreamHostController, failure: GfxstreamHostSnapshot, @@ -1055,6 +1155,7 @@ class RuntimeSupervisorService : Service() { runCatching { val runtime = ProotRuntimeInstaller.install(this) val rootfs = InstalledRootfsResolver.resolve(this, requestedRootfsName) + val graphicsProfile = desktopConfigurationStore.loadGraphicsProfile(rootfs.name) runCatching { audioController.apply(audioConfiguration, bootId) } .onFailure { error -> app.journal.append( @@ -1073,6 +1174,7 @@ class RuntimeSupervisorService : Service() { rootfs = rootfs, x11SocketDirectory = x11SocketDirectory, audioEndpoint = audioController.endpoint(), + launchProfile = graphicsLaunchProfile(graphicsProfile), ) }.mapCatching { launch -> configureTerminalColors() @@ -1182,6 +1284,7 @@ class RuntimeSupervisorService : Service() { val next = app.runtimeState.update { RuntimeStateMachine.afterProcessExit(it, exitCode) } stopApplicationProcesses() + closeVirglHost() x11Controller.stop(beforeExit.bootId) audioController.stop(beforeExit.bootId) publishState(next) @@ -1205,6 +1308,7 @@ class RuntimeSupervisorService : Service() { pendingDesktopRestart.set(null) stopDesktopProcess(restarting = false) stopApplicationProcesses() + closeVirglHost() x11Controller.stop(current.bootId) audioController.stop(current.bootId) val stopping = @@ -1359,7 +1463,9 @@ class RuntimeSupervisorService : Service() { Thread.currentThread().interrupt() return@processWait } - applicationProcesses.remove(application.id, process) + if (applicationProcesses.remove(application.id, process)) { + virglApplicationBackends -= application.id + } app.journal.append( component = "linux-app", severity = if (exitCode == 0) "info" else "warning", @@ -1380,6 +1486,7 @@ class RuntimeSupervisorService : Service() { if (process.isAlive) process.destroy() } applicationProcesses.clear() + virglApplicationBackends.clear() } private fun shellQuote(argument: String): String = @@ -1560,6 +1667,7 @@ class RuntimeSupervisorService : Service() { val rootfsName: String, val environment: DesktopEnvironment, val gfxstreamHost: GfxstreamHostController?, + val graphicsProfile: DesktopGraphicsProfile, val mounts: List, ) } diff --git a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt index fed48fd..491587a 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt @@ -126,6 +126,7 @@ enum class UdroidDestination( DISTROS("Linux", Icons.Rounded.Storage, Icons.Rounded.Storage), INSTALL("Install", Icons.Rounded.Storage, Icons.Rounded.Storage), SYSTEM("System", Icons.Rounded.Storage, Icons.Rounded.Storage), + ENVIRONMENT("Environment", Icons.Rounded.Code, Icons.Rounded.Code), MOUNTS("Mounts", Icons.Rounded.Tune, Icons.Rounded.Tune), MOUNT_EDITOR("Mounts", Icons.Rounded.Tune, Icons.Rounded.Tune), TERMINAL("Terminal", Icons.Rounded.Terminal, Icons.Rounded.Terminal), @@ -160,7 +161,9 @@ private val UdroidDestination.navigationDepth: Int UdroidDestination.INSTALL, UdroidDestination.SYSTEM, -> 1 - UdroidDestination.MOUNTS -> 2 + UdroidDestination.MOUNTS, + UdroidDestination.ENVIRONMENT, + -> 2 UdroidDestination.MOUNT_EDITOR -> 3 else -> 0 } @@ -251,6 +254,7 @@ fun UdroidApp( -> UdroidDestination.DISTROS UdroidDestination.MOUNTS, UdroidDestination.MOUNT_EDITOR, + UdroidDestination.ENVIRONMENT, -> UdroidDestination.DISTROS else -> activeDestination } @@ -302,15 +306,17 @@ fun UdroidApp( val useRail = maxWidth >= 600.dp if (useRail) { Row(modifier = Modifier.fillMaxSize()) { - WorkspaceNavigationRail( - selected = navigationDestination, - destinations = requestedJourney.destinations, - onSelected = onPrimaryDestinationSelected, - ) - HorizontalDivider( - modifier = Modifier.fillMaxHeight().width(1.dp), - color = UdroidLine, - ) + if (activeDestination != UdroidDestination.ENVIRONMENT) { + WorkspaceNavigationRail( + selected = navigationDestination, + destinations = requestedJourney.destinations, + onSelected = onPrimaryDestinationSelected, + ) + HorizontalDivider( + modifier = Modifier.fillMaxHeight().width(1.dp), + color = UdroidLine, + ) + } ManagementPane( modifier = Modifier.weight(1f), destination = activeDestination, @@ -478,17 +484,19 @@ fun UdroidApp( onInstallUpdate = onInstallUpdate, onOpenUpdateRelease = onOpenUpdateRelease, ) - WorkspaceNavigationBar( - selected = navigationDestination, - destinations = - workspaceJourney( - requestedDestination = activeDestination, - hasInstalledLinux = hasInstalledLinux, - hasInstallation = installProgress != null, - compactNavigation = true, - ).destinations, - onSelected = onPrimaryDestinationSelected, - ) + if (activeDestination != UdroidDestination.ENVIRONMENT) { + WorkspaceNavigationBar( + selected = navigationDestination, + destinations = + workspaceJourney( + requestedDestination = activeDestination, + hasInstalledLinux = hasInstalledLinux, + hasInstallation = installProgress != null, + compactNavigation = true, + ).destinations, + onSelected = onPrimaryDestinationSelected, + ) + } } } } @@ -770,6 +778,41 @@ private fun ManagementPane( ) } } + UdroidDestination.ENVIRONMENT -> { + val rootfsName = selectedSystemRootfsName ?: installedRootfsName + val selectedRootfs = + installedRootfses.firstOrNull { it.name == rootfsName } + val selectedDistro = + (catalogueState as? DistroCatalogState.Ready) + ?.catalog + ?.variants + ?.firstOrNull { it.internalName == rootfsName } + if (selectedRootfs == null) { + onDestinationSelected(UdroidDestination.DISTROS) + } else { + ProotEnvironmentProfilePage( + systemId = selectedRootfs.name, + systemTitle = + selectedDistro?.releaseName + ?: installedSystemTitle(selectedRootfs.name), + rootfsDirectory = selectedRootfs.directory, + desktopEnvironment = + desktopEnvironments.firstOrNull { + it.id == desktopConfiguration.environmentId + }, + desktopConfiguration = desktopConfiguration, + runtimeRunning = + snapshot.rootfsName == selectedRootfs.name && + snapshot.phase == RuntimePhase.RUNNING, + desktopRunning = + snapshot.desktop.rootfsName == selectedRootfs.name && + snapshot.desktop.phase == DesktopSessionPhase.RUNNING, + onBack = { + onDestinationSelected(UdroidDestination.SYSTEM) + }, + ) + } + } UdroidDestination.DISTROS -> selectedOciRepository?.let { repository -> OciTagCataloguePage( @@ -879,6 +922,9 @@ private fun ManagementPane( onConfigureMounts = { onSelectMountProfile(mountConfigurationSourceId) }, + onConfigureEnvironment = { + onDestinationSelected(UdroidDestination.ENVIRONMENT) + }, onResetFilesystem = { onResetRootfs(selectedRootfs.name, selectedDistro) }, diff --git a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt index b207ead..e94b647 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Apps import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.DeleteOutline import androidx.compose.material.icons.rounded.DesktopWindows import androidx.compose.material.icons.rounded.Refresh @@ -69,6 +70,8 @@ import org.randomcoder.udroid.runtime.DesktopSessionPhase import org.randomcoder.udroid.runtime.GFXSTREAM_PROFILE_ENABLED import org.randomcoder.udroid.runtime.InstalledRootfs import org.randomcoder.udroid.runtime.PROOT_DEFAULT_MOUNTS +import org.randomcoder.udroid.runtime.ProotEnvironmentProfile +import org.randomcoder.udroid.runtime.ProotEnvironmentProfileStore import org.randomcoder.udroid.runtime.ProotMountProfile import org.randomcoder.udroid.runtime.ProotMountProfileStore import org.randomcoder.udroid.runtime.RuntimePhase @@ -108,6 +111,7 @@ fun LinuxSystemPage( onStopDesktop: () -> Unit, onRestartDesktop: () -> Unit, onConfigureMounts: () -> Unit, + onConfigureEnvironment: () -> Unit, onResetFilesystem: () -> Unit, onDeleteFilesystem: () -> Unit, ) { @@ -115,6 +119,7 @@ fun LinuxSystemPage( val context = androidx.compose.ui.platform.LocalContext.current val openDeveloperOptions = rememberDeveloperOptionsAction(onRefreshCapabilities) val mountProfileStore = remember(context) { ProotMountProfileStore(context) } + val environmentProfileStore = remember(context) { ProotEnvironmentProfileStore(context) } var confirmation by remember(rootfs.name) { mutableStateOf(null) } @@ -122,6 +127,10 @@ fun LinuxSystemPage( runCatching { mountProfileStore.load(rootfs.name) } .getOrDefault(ProotMountProfile()) } + val environmentProfile = remember(rootfs.name) { + runCatching { environmentProfileStore.load(rootfs.name) } + .getOrDefault(ProotEnvironmentProfile()) + } val selectedEnvironment = environments.firstOrNull { it.id == configuration.environmentId } ?: environments.firstOrNull() @@ -290,6 +299,28 @@ fun LinuxSystemPage( ) } + item(key = "graphics-label") { + UdroidSectionLabel( + text = "Graphics", + modifier = Modifier.padding(top = 4.dp), + ) + } + item(key = "graphics-settings") { + Surface( + color = Color.Transparent, + border = BorderStroke(1.dp, UdroidLine), + shape = MaterialTheme.shapes.medium, + ) { + GraphicsProfileSelector( + selected = configuration.graphicsProfile, + runtimeRunning = + runtimeOwnsSystem && snapshot.phase == RuntimePhase.RUNNING, + desktopRunning = desktopRunning, + onSelected = onGraphicsProfileChanged, + ) + } + } + item(key = "desktop-label") { UdroidSectionLabel( text = "Desktop session", @@ -360,7 +391,6 @@ fun LinuxSystemPage( desktopRunning = desktopRunning, onCompositingChanged = onCompositingChanged, onTouchScaleChanged = onTouchScaleChanged, - onGraphicsProfileChanged = onGraphicsProfileChanged, ) } item(key = "desktop-controls") { @@ -458,6 +488,20 @@ fun LinuxSystemPage( ) } + item(key = "environment-label") { + UdroidSectionLabel( + text = "Environment", + modifier = Modifier.padding(top = 4.dp), + ) + } + item(key = "environment-settings") { + EnvironmentProfilePanel( + profile = environmentProfile, + runtimeRunning = runtimeOwnsSystem && snapshot.phase == RuntimePhase.RUNNING, + onConfigure = onConfigureEnvironment, + ) + } + item(key = "filesystem-label") { UdroidSectionLabel( text = "Storage", @@ -648,6 +692,45 @@ fun LinuxSystemPage( } +@Composable +private fun EnvironmentProfilePanel( + profile: ProotEnvironmentProfile, + runtimeRunning: Boolean, + onConfigure: () -> Unit, +) { + val changed = + profile.defaultOverrides.size + + profile.managedOverrides.size + + profile.customVariables.size + Surface( + modifier = Modifier.clickable(onClick = onConfigure), + color = Color.Transparent, + border = BorderStroke(1.dp, UdroidLine), + shape = MaterialTheme.shapes.medium, + ) { + ListItem( + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + headlineContent = { Text("Environment variables") }, + supportingContent = { + Text( + buildString { + append(if (changed == 0) "Defaults" else "$changed changed") + append(" · New launches use changes") + if (runtimeRunning) append(" · Restart Linux for the terminal") + }, + ) + }, + leadingContent = { Icon(Icons.Rounded.Code, contentDescription = null) }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Configure") + Icon(Icons.Rounded.ChevronRight, contentDescription = null) + } + }, + ) + } +} + private enum class FilesystemConfirmation { RESET, DELETE, @@ -971,7 +1054,6 @@ private fun DesktopSettingsPanel( desktopRunning: Boolean, onCompositingChanged: (Boolean) -> Unit, onTouchScaleChanged: (Boolean) -> Unit, - onGraphicsProfileChanged: (DesktopGraphicsProfile) -> Unit, ) { val compositorSupport = environment.kind.compositorSupport val compositorConfigurable = @@ -1017,14 +1099,6 @@ private fun DesktopSettingsPanel( enabled = true, onCheckedChange = onTouchScaleChanged, ) - if (GFXSTREAM_PROFILE_ENABLED) { - HorizontalDivider(color = UdroidLine) - GraphicsProfileSelector( - selected = configuration.graphicsProfile, - desktopRunning = desktopRunning, - onSelected = onGraphicsProfileChanged, - ) - } } } } @@ -1032,10 +1106,10 @@ private fun DesktopSettingsPanel( @Composable private fun GraphicsProfileSelector( selected: DesktopGraphicsProfile, + runtimeRunning: Boolean, desktopRunning: Boolean, onSelected: (DesktopGraphicsProfile) -> Unit, ) { - val gfxstreamSupported = "arm64-v8a" in Build.SUPPORTED_ABIS Column(modifier = Modifier.padding(vertical = 8.dp)) { Text( "Graphics driver", @@ -1043,26 +1117,76 @@ private fun GraphicsProfileSelector( color = UdroidInk, style = MaterialTheme.typography.titleMedium, ) + Text( + when { + runtimeRunning && desktopRunning -> + "New apps use this choice. Restart Linux for the terminal and restart " + + "the desktop for the current desktop session." + runtimeRunning -> + "New apps use this choice. Restart Linux to update the terminal." + desktopRunning -> + "New apps use this choice. Restart the desktop to update it." + else -> "New terminal, app, and desktop launches use this choice." + }, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) GraphicsProfileRow( - title = "Standard", - detail = "Use the distribution’s default graphics driver", + title = "Automatic", + detail = "Use the distribution’s default graphics driver.", selected = selected == DesktopGraphicsProfile.STANDARD, enabled = true, onClick = { onSelected(DesktopGraphicsProfile.STANDARD) }, ) GraphicsProfileRow( - title = "gfxstream (experimental)", - detail = - if (gfxstreamSupported) { - "Use Android’s Vulkan driver." + - if (desktopRunning) " Restart the desktop to apply." else "" - } else { - "Available only on arm64 devices" - }, - selected = selected == DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL, - enabled = gfxstreamSupported, - onClick = { onSelected(DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL) }, + title = "Software", + detail = "Use Mesa llvmpipe on the CPU.", + selected = selected == DesktopGraphicsProfile.SOFTWARE, + enabled = true, + onClick = { onSelected(DesktopGraphicsProfile.SOFTWARE) }, + ) + GraphicsProfileRow( + title = "Zink", + detail = "Use OpenGL over a working Vulkan driver installed inside Linux.", + selected = selected == DesktopGraphicsProfile.ZINK, + enabled = true, + onClick = { onSelected(DesktopGraphicsProfile.ZINK) }, ) + if ("arm64-v8a" in Build.SUPPORTED_ABIS) { + GraphicsProfileRow( + title = "VirGL", + detail = + "Use Android OpenGL ES with a current Mesa virpipe driver inside Linux.", + selected = selected == DesktopGraphicsProfile.VIRGL, + enabled = true, + onClick = { onSelected(DesktopGraphicsProfile.VIRGL) }, + ) + GraphicsProfileRow( + title = "VirGL + ANGLE", + detail = + "Use VirGL through ANGLE and Android Vulkan with a current Mesa " + + "virpipe driver inside Linux.", + selected = selected == DesktopGraphicsProfile.VIRGL_ANGLE, + enabled = true, + onClick = { onSelected(DesktopGraphicsProfile.VIRGL_ANGLE) }, + ) + } + if (GFXSTREAM_PROFILE_ENABLED) { + val gfxstreamSupported = "arm64-v8a" in Build.SUPPORTED_ABIS + GraphicsProfileRow( + title = "gfxstream (experimental)", + detail = + if (gfxstreamSupported) { + "Use Android’s Vulkan driver" + } else { + "Available only on arm64 devices" + }, + selected = selected == DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL, + enabled = gfxstreamSupported, + onClick = { onSelected(DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL) }, + ) + } } } diff --git a/app/src/main/java/org/randomcoder/udroid/ui/ProotEnvironmentProfilesPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/ProotEnvironmentProfilesPage.kt new file mode 100644 index 0000000..4b96456 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/ui/ProotEnvironmentProfilesPage.kt @@ -0,0 +1,597 @@ +package org.randomcoder.udroid.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.randomcoder.udroid.audio.AudioEndpoint +import org.randomcoder.udroid.runtime.PROOT_DEFAULT_ENVIRONMENT_VARIABLES +import org.randomcoder.udroid.runtime.PROOT_LOCKED_ENVIRONMENT_VARIABLE_NAMES +import org.randomcoder.udroid.runtime.DesktopConfiguration +import org.randomcoder.udroid.runtime.DesktopEnvironment +import org.randomcoder.udroid.runtime.DesktopGraphicsProfile +import org.randomcoder.udroid.runtime.ProotCustomEnvironmentVariable +import org.randomcoder.udroid.runtime.ProotEnvironmentProfile +import org.randomcoder.udroid.runtime.ProotEnvironmentProfileStore +import org.randomcoder.udroid.runtime.ProotEnvironmentProfileValidator +import org.randomcoder.udroid.runtime.ProotTerminalLaunchBuilder +import java.io.File + +@Composable +fun ProotEnvironmentProfilePage( + systemId: String, + systemTitle: String, + rootfsDirectory: File, + desktopEnvironment: DesktopEnvironment?, + desktopConfiguration: DesktopConfiguration, + runtimeRunning: Boolean, + desktopRunning: Boolean, + onBack: () -> Unit, +) { + val context = LocalContext.current + val store = remember(context) { ProotEnvironmentProfileStore(context) } + val initialProfile = + remember(systemId) { + runCatching { store.load(systemId) }.getOrDefault(ProotEnvironmentProfile()) + } + var persistedProfile by remember(systemId) { mutableStateOf(initialProfile) } + var draft by remember(systemId) { mutableStateOf(initialProfile) } + var message by remember(systemId) { mutableStateOf(null) } + var saving by remember(systemId) { mutableStateOf(false) } + var confirmDiscard by remember(systemId) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val dirty = draft != persistedProfile + val managedValues = + remember(rootfsDirectory, desktopEnvironment, desktopConfiguration) { + automaticManagedEnvironmentValues( + rootfsDirectory = rootfsDirectory, + desktopEnvironment = desktopEnvironment, + desktopConfiguration = desktopConfiguration, + ) + } + + fun requestBack() { + if (dirty) confirmDiscard = true else onBack() + } + + fun save() { + if (saving || !dirty) return + val validated = + runCatching { ProotEnvironmentProfileValidator.requireValid(draft) } + .onFailure { message = it.message ?: "The environment profile is invalid" } + .getOrNull() ?: return + saving = true + scope.launch { + val result = runCatching { withContext(Dispatchers.IO) { store.save(systemId, validated) } } + saving = false + result.fold( + onSuccess = { + persistedProfile = it + draft = it + message = "Environment saved. New launches use these values." + }, + onFailure = { + message = it.message ?: "The environment could not be saved" + }, + ) + } + } + + BackHandler(onBack = ::requestBack) + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "environment-header") { + Row( + modifier = Modifier.padding(top = 10.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = ::requestBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back to Linux system", + ) + } + Column(modifier = Modifier.weight(1f).padding(start = 6.dp)) { + Text("Environment variables", style = MaterialTheme.typography.headlineSmall) + Text( + systemTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = UdroidMuted, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + item(key = "environment-apply-note") { + Surface(color = UdroidInset, shape = MaterialTheme.shapes.medium) { + Text( + launchEffectText(runtimeRunning, desktopRunning), + modifier = Modifier.padding(12.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + item(key = "environment-secret-warning") { + Text( + "Do not store passwords, tokens, or other secrets here. Values are saved as " + + "plain app data and passed directly to launched processes.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + item(key = "environment-defaults-label") { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Launch defaults", style = MaterialTheme.typography.titleMedium) + Text( + "${draft.defaultOverrides.size} changed", + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton( + enabled = draft.defaultOverrides.isNotEmpty(), + onClick = { + draft = draft.copy(defaultOverrides = emptyMap()) + message = null + }, + ) { + Icon(Icons.Rounded.RestartAlt, contentDescription = null) + Spacer(Modifier.width(5.dp)) + Text("Restore defaults") + } + } + } + + items( + items = PROOT_DEFAULT_ENVIRONMENT_VARIABLES, + key = { "default-${it.name}" }, + ) { variable -> + val value = draft.defaultOverrides[variable.name] ?: variable.defaultValue + OutlinedTextField( + value = value, + onValueChange = { changed -> + val overrides = draft.defaultOverrides.toMutableMap() + if (changed == variable.defaultValue) { + overrides.remove(variable.name) + } else { + overrides[variable.name] = changed + } + draft = draft.copy(defaultOverrides = overrides) + message = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(variable.name) }, + supportingText = { + if (variable.name in draft.defaultOverrides) { + TextButton( + contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp), + onClick = { + draft = + draft.copy( + defaultOverrides = draft.defaultOverrides - variable.name, + ) + message = null + }, + ) { + Text("Restore ${variable.defaultValue}") + } + } else { + Text("Default") + } + }, + singleLine = true, + shape = RoundedCornerShape(10.dp), + ) + } + + item(key = "environment-custom-label") { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Custom variables", style = MaterialTheme.typography.titleMedium) + Text( + "Added to every new launch", + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton( + onClick = { + draft = + draft.copy( + customVariables = + draft.customVariables + + ProotCustomEnvironmentVariable(name = "", value = ""), + ) + message = null + }, + ) { + Icon(Icons.Rounded.Add, contentDescription = null) + Spacer(Modifier.width(5.dp)) + Text("Add") + } + } + } + + if (draft.customVariables.isEmpty()) { + item(key = "environment-custom-empty") { + Surface(color = UdroidInset, shape = MaterialTheme.shapes.medium) { + Text( + "No custom variables added.", + modifier = Modifier.fillMaxWidth().padding(12.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + items(draft.customVariables, key = ProotCustomEnvironmentVariable::id) { variable -> + CustomEnvironmentVariableEditor( + variable = variable, + onChange = { changed -> + draft = + draft.copy( + customVariables = + draft.customVariables.map { + if (it.id == variable.id) changed else it + }, + ) + message = null + }, + onDelete = { + draft = + draft.copy( + customVariables = + draft.customVariables.filterNot { it.id == variable.id }, + ) + message = null + }, + ) + } + + item(key = "environment-managed-label") { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Managed by uDroid", style = MaterialTheme.typography.titleMedium) + Text( + "${draft.managedOverrides.size} overridden · Overrides apply last", + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton( + enabled = draft.managedOverrides.isNotEmpty(), + onClick = { + draft = draft.copy(managedOverrides = emptyMap()) + message = null + }, + ) { + Icon(Icons.Rounded.RestartAlt, contentDescription = null) + Spacer(Modifier.width(5.dp)) + Text("Reset all") + } + } + } + + item(key = "environment-managed-list") { + Surface( + color = Color.Transparent, + border = BorderStroke(1.dp, UdroidLine), + shape = RoundedCornerShape(12.dp), + ) { + Column { + PROOT_LOCKED_ENVIRONMENT_VARIABLE_NAMES + .sorted() + .forEachIndexed { index, name -> + ManagedEnvironmentVariableEditor( + name = name, + automaticValue = managedValues.getValue(name), + overrideValue = draft.managedOverrides[name], + overridden = name in draft.managedOverrides, + onOverride = { + draft = + draft.copy( + managedOverrides = + draft.managedOverrides + + (name to managedValues.getValue(name).suggestedOverride), + ) + message = null + }, + onChange = { value -> + draft = + draft.copy( + managedOverrides = draft.managedOverrides + (name to value), + ) + message = null + }, + onReset = { + draft = + draft.copy( + managedOverrides = draft.managedOverrides - name, + ) + message = null + }, + ) + if (index != PROOT_LOCKED_ENVIRONMENT_VARIABLE_NAMES.size - 1) { + HorizontalDivider(color = UdroidLine) + } + } + } + } + } + + message?.let { visibleMessage -> + item(key = "environment-message") { + Text( + visibleMessage, + color = + if (visibleMessage.startsWith("Environment saved")) { + UdroidForest + } else { + MaterialTheme.colorScheme.error + }, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + item(key = "environment-save") { + Button( + modifier = Modifier.fillMaxWidth(), + enabled = dirty && !saving, + onClick = ::save, + shape = RoundedCornerShape(10.dp), + ) { + Text(if (saving) "Saving…" else "Save changes") + } + } + + item { Spacer(Modifier.height(24.dp)) } + } + + if (confirmDiscard) { + AlertDialog( + onDismissRequest = { confirmDiscard = false }, + title = { Text("Unsaved changes") }, + text = { Text("Your environment variable changes have not been saved.") }, + dismissButton = { + TextButton(onClick = { confirmDiscard = false }) { Text("Keep editing") } + }, + confirmButton = { + TextButton(onClick = onBack) { Text("Discard") } + }, + ) + } +} + +@Composable +private fun CustomEnvironmentVariableEditor( + variable: ProotCustomEnvironmentVariable, + onChange: (ProotCustomEnvironmentVariable) -> Unit, + onDelete: () -> Unit, +) { + Surface( + color = Color.Transparent, + border = BorderStroke(1.dp, UdroidLine), + shape = RoundedCornerShape(12.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Variable", modifier = Modifier.weight(1f)) + TextButton(onClick = onDelete) { + Icon(Icons.Rounded.DeleteOutline, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text("Delete") + } + } + OutlinedTextField( + value = variable.name, + onValueChange = { onChange(variable.copy(name = it)) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Name") }, + singleLine = true, + ) + OutlinedTextField( + value = variable.value, + onValueChange = { onChange(variable.copy(value = it)) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Value") }, + singleLine = true, + ) + } + } +} + +@Composable +private fun ManagedEnvironmentVariableEditor( + name: String, + automaticValue: ManagedEnvironmentValue, + overrideValue: String?, + overridden: Boolean, + onOverride: () -> Unit, + onChange: (String) -> Unit, + onReset: () -> Unit, +) { + if (!overridden) { + ListItem( + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + headlineContent = { Text(name, fontFamily = FontFamily.Monospace) }, + supportingContent = { + Text(automaticValue.display, fontFamily = FontFamily.Monospace) + }, + trailingContent = { TextButton(onClick = onOverride) { Text("Override") } }, + ) + return + } + + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(name, modifier = Modifier.weight(1f), fontFamily = FontFamily.Monospace) + TextButton(onClick = onReset) { + Icon(Icons.Rounded.RestartAlt, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text("Reset") + } + } + OutlinedTextField( + value = overrideValue.orEmpty(), + onValueChange = onChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Override value") }, + supportingText = { + Text("uDroid: ${automaticValue.display}", fontFamily = FontFamily.Monospace) + }, + singleLine = true, + ) + } +} + +private fun launchEffectText( + runtimeRunning: Boolean, + desktopRunning: Boolean, +): String = + when { + runtimeRunning && desktopRunning -> + "New app and desktop launches use saved changes. Restart Linux for the current " + + "terminal. Existing processes are unchanged." + runtimeRunning -> + "New app and desktop launches use saved changes. Restart Linux for the current " + + "terminal. Existing processes are unchanged." + desktopRunning -> + "New launches use saved changes. Restart the desktop to update its current session. " + + "Existing processes are unchanged." + else -> "New terminal, app, and desktop launches use saved changes." + } + +private data class ManagedEnvironmentValue( + val display: String, + val suggestedOverride: String, +) + +private fun automaticManagedEnvironmentValues( + rootfsDirectory: File, + desktopEnvironment: DesktopEnvironment?, + desktopConfiguration: DesktopConfiguration, +): Map { + val guestHome = if (File(rootfsDirectory, "root").isDirectory) "/root" else "/" + val guestShell = ProotTerminalLaunchBuilder.findGuestShell(rootfsDirectory) ?: "/bin/sh" + val desktopName = desktopEnvironment?.kind?.desktopName + val desktopId = desktopEnvironment?.id + val values = + PROOT_LOCKED_ENVIRONMENT_VARIABLE_NAMES.associateWith { + ManagedEnvironmentValue("", "") + }.toMutableMap() + + fun set( + name: String, + value: String, + display: String = value, + ) { + values[name] = ManagedEnvironmentValue(display, value) + } + + set("HOME", guestHome) + set("USER", "root") + set("LOGNAME", "root") + set( + "SHELL", + guestShell, + if (guestShell == "/bin/sh") "/bin/sh" else "terminal=$guestShell · apps/desktops=/bin/sh", + ) + set("DISPLAY", ":0") + set("PULSE_SERVER", AudioEndpoint.GUEST_SERVER) + set( + "PULSE_COOKIE", + "${AudioEndpoint.GUEST_AUTH_DIRECTORY}/${AudioEndpoint.COOKIE_NAME}", + ) + set("XDG_SESSION_TYPE", "x11", "desktop=x11") + set( + "XDG_CURRENT_DESKTOP", + desktopName ?: "UDROID", + "apps=UDROID · desktop=${desktopName ?: ""}", + ) + if (desktopId != null) set("DESKTOP_SESSION", desktopId, "desktop=$desktopId") + set("GDK_BACKEND", "x11", "apps/desktops=x11") + set("QT_QPA_PLATFORM", "xcb", "apps/desktops=xcb") + if (desktopConfiguration.touchScaleEnabled) { + set("GDK_SCALE", "2", "desktop=2") + set("QT_SCALE_FACTOR", "2", "desktop=2") + set("XCURSOR_SIZE", "48", "desktop=48") + } + + when (desktopConfiguration.graphicsProfile) { + DesktopGraphicsProfile.STANDARD -> Unit + DesktopGraphicsProfile.SOFTWARE -> { + set("LIBGL_ALWAYS_SOFTWARE", "1") + set("GALLIUM_DRIVER", "llvmpipe") + } + DesktopGraphicsProfile.ZINK -> { + set("MESA_LOADER_DRIVER_OVERRIDE", "zink") + set("GALLIUM_DRIVER", "zink") + set("LIBGL_KOPPER_DRI2", "true") + } + DesktopGraphicsProfile.VIRGL, + DesktopGraphicsProfile.VIRGL_ANGLE, + -> set("GALLIUM_DRIVER", "virpipe") + DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL -> Unit + } + return values +} diff --git a/app/src/main/java/org/randomcoder/udroid/ui/WorkspaceJourney.kt b/app/src/main/java/org/randomcoder/udroid/ui/WorkspaceJourney.kt index 415fdab..391c4c6 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/WorkspaceJourney.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/WorkspaceJourney.kt @@ -39,6 +39,7 @@ fun workspaceJourney( it == UdroidDestination.INSTALL || it == UdroidDestination.MOUNTS || it == UdroidDestination.MOUNT_EDITOR || + it == UdroidDestination.ENVIRONMENT || it == UdroidDestination.DESKTOP || (compactNavigation && it == UdroidDestination.DEVICE) } @@ -63,5 +64,6 @@ val UdroidDestination.requiresInstalledLinux: Boolean this == UdroidDestination.SYSTEM || this == UdroidDestination.MOUNTS || this == UdroidDestination.MOUNT_EDITOR || + this == UdroidDestination.ENVIRONMENT || this == UdroidDestination.APPS || this == UdroidDestination.DESKTOP diff --git a/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostController.kt b/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostController.kt new file mode 100644 index 0000000..9c03c18 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostController.kt @@ -0,0 +1,212 @@ +package org.randomcoder.udroid.virgl + +import android.content.Context +import android.util.Log +import java.io.File +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import org.randomcoder.udroid.runtime.AndroidExecutableCommand +import org.randomcoder.udroid.runtime.DesktopGraphicsProfile + +internal enum class VirglHostBackend( + val label: String, + val socketName: String, + val logName: String, +) { + NATIVE_GLES("Android OpenGL ES", "virgl-gles.sock", "virgl-gles.log"), + ANGLE_VULKAN("ANGLE Vulkan", "virgl-angle-vulkan.sock", "virgl-angle-vulkan.log"), + ; + + companion object { + fun from(profile: DesktopGraphicsProfile): VirglHostBackend? = + when (profile) { + DesktopGraphicsProfile.VIRGL -> NATIVE_GLES + DesktopGraphicsProfile.VIRGL_ANGLE -> ANGLE_VULKAN + else -> null + } + } +} + +internal data class VirglHostSnapshot( + val state: String = "stopped", + val detail: String = "VirGL host is stopped", +) + +internal object VirglHostLaunch { + fun arguments( + socket: File, + backend: VirglHostBackend, + ): List = + buildList { + add("--no-fork") + add("--multi-clients") + if (backend == VirglHostBackend.ANGLE_VULKAN) add("--angle-vulkan") + add("--socket-path") + add(socket.absolutePath) + } + + fun environment( + home: File, + libraryDirectory: File, + temporaryDirectory: File, + angleLibraryDirectory: File? = null, + ): Map = + mapOf( + "ANDROID_DATA" to "/data", + "ANDROID_ROOT" to "/system", + "ANDROID_RUNTIME_ROOT" to "/apex/com.android.runtime", + "ANDROID_TZDATA_ROOT" to "/apex/com.android.tzdata", + "HOME" to home.absolutePath, + "LD_LIBRARY_PATH" to libraryDirectory.absolutePath, + "PATH" to "/system/bin", + "TMPDIR" to temporaryDirectory.absolutePath, + ) + + if (angleLibraryDirectory == null) { + emptyMap() + } else { + mapOf("VTEST_ANGLE_LIBRARY_PATH" to angleLibraryDirectory.absolutePath) + } +} + +/** Owns one backend-specific VirGL server shared by graphical processes. */ +internal class VirglHostController( + context: Context, + val backend: VirglHostBackend, + private val onUnexpectedExit: (VirglHostSnapshot) -> Unit = {}, +) : AutoCloseable { + private val appContext = context.applicationContext + private val executor = Executors.newSingleThreadExecutor() + private val process = AtomicReference(null) + private val snapshot = AtomicReference(VirglHostSnapshot()) + private val closed = AtomicBoolean(false) + private val lock = Any() + private val graphicsDirectory = + File(appContext.noBackupFilesDir, "graphics").apply { + check(mkdirs() || isDirectory) { "Could not prepare the private graphics directory" } + } + private val socket = File(graphicsDirectory, backend.socketName) + private val logFile = File(graphicsDirectory, backend.logName) + + fun start(): File { + synchronized(lock) { + check(!closed.get()) { "The VirGL host controller is closed" } + currentSocket()?.let { return it } + check(process.get() == null) { "The VirGL host is already starting" } + snapshot.set(VirglHostSnapshot("starting", "installing the VirGL host")) + } + return try { + val runtime = VirglHostRuntimeInstaller.install(appContext) + val angleRuntime = + if (backend == VirglHostBackend.ANGLE_VULKAN) { + VirglAngleRuntimeInstaller.install(appContext) + } else { + null + } + socket.delete() + logFile.delete() + val launched = + ProcessBuilder( + AndroidExecutableCommand.create( + runtime.executable, + *VirglHostLaunch.arguments(socket, backend).toTypedArray(), + ), + ).directory(graphicsDirectory) + .redirectErrorStream(true) + .redirectOutput(logFile) + .apply { + environment().clear() + environment().putAll( + VirglHostLaunch.environment( + appContext.filesDir, + runtime.libraryDirectory, + appContext.cacheDir, + angleRuntime?.libraryDirectory, + ), + ) + }.start() + synchronized(lock) { + check(!closed.get() && process.compareAndSet(null, launched)) { + launched.destroyForcibly() + "The VirGL host was cancelled before startup completed" + } + } + awaitSocket(launched) + snapshot.set( + VirglHostSnapshot( + "running", + "VirGL ${VirglHostRuntimeInstaller.VERSION} · ${backend.label} · " + + "socket ${socket.name}", + ), + ) + Log.i(LOG_TAG, "VirGL host ready; output: ${logFile.absolutePath}") + executor.execute { monitor(launched) } + socket + } catch (error: Throwable) { + process.getAndSet(null)?.let { if (it.isAlive) it.destroyForcibly() } + socket.delete() + if (!closed.get()) { + snapshot.set(VirglHostSnapshot("failed", error.message ?: "VirGL failed")) + } + throw error + } + } + + fun currentSocket(): File? = + socket.takeIf { snapshot.get().state == "running" && it.exists() } + + fun current(): VirglHostSnapshot = snapshot.get() + + private fun awaitSocket(launched: Process) { + repeat(SOCKET_ATTEMPTS) { + if (socket.exists()) return + check(launched.isAlive) { + "VirGL exited before publishing its socket · ${logFile.absolutePath}" + } + Thread.sleep(SOCKET_POLL_MS) + } + error("VirGL did not publish its socket · ${logFile.absolutePath}") + } + + private fun monitor(launched: Process) { + val exitCode = + try { + launched.waitFor() + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return + } + if (process.compareAndSet(launched, null)) { + socket.delete() + val result = + VirglHostSnapshot( + if (exitCode == 0) "stopped" else "failed", + "VirGL exited with $exitCode · ${logFile.absolutePath}", + ) + snapshot.set(result) + Log.w(LOG_TAG, result.detail) + if (!closed.get()) onUnexpectedExit(result) + } + } + + override fun close() { + synchronized(lock) { + closed.set(true) + process.getAndSet(null)?.let { + if (it.isAlive) { + it.destroy() + if (it.isAlive) it.destroyForcibly() + } + } + snapshot.set(VirglHostSnapshot()) + } + socket.delete() + executor.shutdownNow() + } + + private companion object { + const val LOG_TAG = "uDroid-VirGL" + const val SOCKET_ATTEMPTS = 250 + const val SOCKET_POLL_MS = 20L + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostRuntime.kt b/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostRuntime.kt new file mode 100644 index 0000000..4356d45 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostRuntime.kt @@ -0,0 +1,71 @@ +package org.randomcoder.udroid.virgl + +import android.content.Context +import java.io.File +import org.randomcoder.udroid.gfxstream.VerifiedRuntimeAssetBundle +import org.randomcoder.udroid.gfxstream.VerifiedRuntimeAssetInstaller + +internal data class VirglHostRuntime( + val executable: File, + val libraryDirectory: File, +) + +internal data class VirglAngleRuntime( + val libraryDirectory: File, +) + +internal object VirglHostRuntimeInstaller { + const val VERSION = "1.3.0-1" + private val bundle = + VerifiedRuntimeAssetBundle( + name = "VirGL host", + assetDirectory = "virgl-host", + destinationPrefix = "virgl-host", + version = VERSION, + entries = + listOf( + "bin/virgl_test_server_android", + "lib/libepoxy.so", + "lib/libvirglrenderer.so", + "share/doc/COPYING-gl4es", + "share/doc/COPYING-libepoxy", + "share/doc/COPYING-virglrenderer", + ), + executables = setOf("bin/virgl_test_server_android"), + metadataKeys = setOf("termux_packages_commit"), + ) + + fun install(context: Context): VirglHostRuntime { + val directory = VerifiedRuntimeAssetInstaller.install(context, bundle) + return VirglHostRuntime( + executable = File(directory, "bin/virgl_test_server_android"), + libraryDirectory = File(directory, "lib"), + ) + } +} + +internal object VirglAngleRuntimeInstaller { + const val VERSION = "2.1.24923-f09a19ce-2" + private val bundle = + VerifiedRuntimeAssetBundle( + name = "VirGL ANGLE Vulkan backend", + assetDirectory = "virgl-angle-vulkan", + destinationPrefix = "virgl-angle-vulkan", + version = VERSION, + entries = + listOf( + "lib/libEGL_angle.so", + "lib/libGLESv1_CM_angle.so", + "lib/libGLESv2_angle.so", + "lib/libfeature_support_angle.so", + "share/doc/copyright", + ), + metadataKeys = setOf("termux_packages_commit"), + ) + + fun install(context: Context): VirglAngleRuntime = + VirglAngleRuntime( + libraryDirectory = + File(VerifiedRuntimeAssetInstaller.install(context, bundle), "lib"), + ) +} diff --git a/app/src/main/java/org/randomcoder/udroid/virgl/VirglProotLaunchProfile.kt b/app/src/main/java/org/randomcoder/udroid/virgl/VirglProotLaunchProfile.kt new file mode 100644 index 0000000..740b16b --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/virgl/VirglProotLaunchProfile.kt @@ -0,0 +1,29 @@ +package org.randomcoder.udroid.virgl + +import java.io.File +import org.randomcoder.udroid.runtime.ProotLaunchProfile + +internal data class VirglProotLaunchProfile( + val socket: File, +) : ProotLaunchProfile { + init { + require(socket.exists()) { "The VirGL socket is unavailable" } + } + + override fun addBindings(arguments: MutableList) { + arguments += "-b" + arguments += "${socket.absolutePath}:$GUEST_SOCKET" + } + + override fun wrapGuestCommand(command: List): List = + listOf( + "/usr/bin/env", + "-u", + "LIBGL_ALWAYS_SOFTWARE", + "GALLIUM_DRIVER=virpipe", + ) + command + + companion object { + const val GUEST_SOCKET = "/tmp/.virgl_test" + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt index 26e3882..4c4a924 100644 --- a/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt @@ -171,6 +171,79 @@ class DesktopEnvironmentScannerTest { assertEquals("startxfce4", arguments.last()) } + @Test + fun `desktop launch wraps the session with the selected graphics environment`() { + val desktop = + DesktopEnvironment( + id = "xfce", + name = "Xfce", + command = listOf("startxfce4"), + desktopFilePath = "/usr/share/xsessions/xfce.desktop", + kind = DesktopEnvironmentKind.XFCE, + ) + val arguments = + ProotDesktopLaunchBuilder.buildArguments( + prootPath = "/data/proot", + rootfsPath = "/data/rootfs", + x11SocketDirectory = "/data/x11/.X11-unix", + guestHome = "/root", + environment = desktop, + configuration = DesktopConfiguration(desktop.id, false, false), + hasDbusRunSession = false, + launchProfile = + EnvironmentProotLaunchProfile.from(DesktopGraphicsProfile.SOFTWARE), + ) + + assertEquals( + listOf( + "/usr/bin/env", + "LIBGL_ALWAYS_SOFTWARE=1", + "GALLIUM_DRIVER=llvmpipe", + "/bin/sh", + ), + arguments.subList( + arguments.lastIndexOf("/usr/bin/env"), + arguments.lastIndexOf("/usr/bin/env") + 4, + ), + ) + assertEquals("startxfce4", arguments.last()) + } + + @Test + fun `desktop receives saved guest variables before locked desktop variables`() { + val desktop = + DesktopEnvironment( + id = "xfce", + name = "Xfce", + command = listOf("startxfce4"), + desktopFilePath = "/usr/share/xsessions/xfce.desktop", + kind = DesktopEnvironmentKind.XFCE, + ) + val arguments = + ProotDesktopLaunchBuilder.buildArguments( + prootPath = "/data/proot", + rootfsPath = "/data/rootfs", + x11SocketDirectory = "/data/x11/.X11-unix", + guestHome = "/root", + environment = desktop, + configuration = DesktopConfiguration(desktop.id, false, false), + hasDbusRunSession = false, + guestEnvironment = + ProotEnvironmentResolver.resolve( + ProotEnvironmentProfile( + customVariables = + listOf(ProotCustomEnvironmentVariable("custom", "GTK_THEME", "Adwaita dark")), + ), + ), + ) + + assertTrue(arguments.indexOf("GTK_THEME=Adwaita dark") < arguments.indexOf("DISPLAY=:0")) + assertTrue( + arguments.indexOf("GTK_THEME=Adwaita dark") < + arguments.indexOf("XDG_CURRENT_DESKTOP=XFCE"), + ) + } + @Test fun `unknown stored graphics profile safely falls back to standard`() { assertEquals( @@ -179,6 +252,26 @@ class DesktopEnvironmentScannerTest { ) } + @Test + fun `environment graphics profiles survive storage`() { + assertEquals( + DesktopGraphicsProfile.SOFTWARE, + DesktopGraphicsProfile.fromStorage(DesktopGraphicsProfile.SOFTWARE.storageValue), + ) + assertEquals( + DesktopGraphicsProfile.ZINK, + DesktopGraphicsProfile.fromStorage(DesktopGraphicsProfile.ZINK.storageValue), + ) + assertEquals( + DesktopGraphicsProfile.VIRGL, + DesktopGraphicsProfile.fromStorage(DesktopGraphicsProfile.VIRGL.storageValue), + ) + assertEquals( + DesktopGraphicsProfile.VIRGL_ANGLE, + DesktopGraphicsProfile.fromStorage(DesktopGraphicsProfile.VIRGL_ANGLE.storageValue), + ) + } + @Test fun `inactive gfxstream profile safely falls back to standard`() { assertEquals( diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt index 4e8e33a..01a6ed0 100644 --- a/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt @@ -77,4 +77,57 @@ class ProotApplicationLaunchTest { arguments.takeLast(3), ) } + + @Test + fun wrapsGraphicalApplicationsWithTheSelectedGraphicsEnvironment() { + val arguments = + ProotApplicationLaunchBuilder.buildArguments( + prootPath = "/data/proot", + rootfsPath = "/data/rootfs", + x11SocketDirectory = "/data/x11/.X11-unix", + guestHome = "/root", + guestWorkingDirectory = "/root", + applicationArguments = listOf("/usr/bin/glxinfo", "-B"), + launchProfile = + EnvironmentProotLaunchProfile.from(DesktopGraphicsProfile.ZINK), + ) + + assertEquals( + listOf( + "/usr/bin/env", + "MESA_LOADER_DRIVER_OVERRIDE=zink", + "GALLIUM_DRIVER=zink", + "LIBGL_KOPPER_DRI2=true", + "/usr/bin/glxinfo", + "-B", + ), + arguments.takeLast(6), + ) + } + + @Test + fun `graphical application receives saved guest variables without shell parsing`() { + val arguments = + ProotApplicationLaunchBuilder.buildArguments( + prootPath = "/data/proot", + rootfsPath = "/data/rootfs", + x11SocketDirectory = "/data/x11/.X11-unix", + guestHome = "/root", + guestWorkingDirectory = "/root", + applicationArguments = listOf("/usr/bin/demo"), + guestEnvironment = + ProotEnvironmentResolver.resolve( + ProotEnvironmentProfile( + customVariables = + listOf( + ProotCustomEnvironmentVariable("custom", "APP_FLAGS", "one two=three"), + ), + ), + ), + ) + + assertTrue("APP_FLAGS=one two=three" in arguments) + assertTrue(arguments.indexOf("APP_FLAGS=one two=three") < arguments.indexOf("DISPLAY=:0")) + assertEquals("/usr/bin/demo", arguments.last()) + } } diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotEnvironmentProfilesTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotEnvironmentProfilesTest.kt new file mode 100644 index 0000000..becf174 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotEnvironmentProfilesTest.kt @@ -0,0 +1,126 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class ProotEnvironmentProfilesTest { + @Test + fun `default profile resolves the managed guest defaults in order`() { + assertEquals( + listOf( + "LANG=C.UTF-8", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "TERM=xterm-256color", + "COLORTERM=truecolor", + ), + ProotEnvironmentResolver.resolve(), + ) + } + + @Test + fun `codec preserves overrides ids and argv-safe values verbatim`() { + val profile = + ProotEnvironmentProfile( + defaultOverrides = mapOf("LANG" to "en_IN.UTF-8"), + managedOverrides = mapOf("DISPLAY" to ":7", "GALLIUM_DRIVER" to "custom"), + customVariables = + listOf( + ProotCustomEnvironmentVariable("empty-id", "EMPTY", ""), + ProotCustomEnvironmentVariable("spaces-id", "GREETING", "hello world"), + ProotCustomEnvironmentVariable("equals-id", "TOKEN", "a=b=c"), + ProotCustomEnvironmentVariable("unicode-id", "CITY", "Hyderabad 🌆"), + ), + ) + + val decoded = ProotEnvironmentProfileCodec.decode(ProotEnvironmentProfileCodec.encode(profile)) + + assertEquals(profile, decoded) + assertEquals( + listOf("DISPLAY=:7", "GALLIUM_DRIVER=custom"), + ProotEnvironmentResolver.resolveManagedOverrides(decoded), + ) + assertEquals("EMPTY=", ProotEnvironmentResolver.resolve(decoded).first { it.startsWith("EMPTY=") }) + assertEquals( + "GREETING=hello world", + ProotEnvironmentResolver.resolve(decoded).first { it.startsWith("GREETING=") }, + ) + assertEquals( + "TOKEN=a=b=c", + ProotEnvironmentResolver.resolve(decoded).first { it.startsWith("TOKEN=") }, + ) + } + + @Test + fun `validator rejects invalid duplicate default and locked names`() { + val profiles = + listOf( + ProotEnvironmentProfile( + customVariables = listOf(ProotCustomEnvironmentVariable("one", "9INVALID", "x")), + ), + ProotEnvironmentProfile( + customVariables = + listOf( + ProotCustomEnvironmentVariable("one", "CUSTOM", "x"), + ProotCustomEnvironmentVariable("two", "CUSTOM", "y"), + ), + ), + ProotEnvironmentProfile( + customVariables = listOf(ProotCustomEnvironmentVariable("one", "LANG", "x")), + ), + ProotEnvironmentProfile( + customVariables = listOf(ProotCustomEnvironmentVariable("one", "DISPLAY", ":9")), + ), + ProotEnvironmentProfile(managedOverrides = mapOf("NOT_MANAGED" to "x")), + ProotEnvironmentProfile( + customVariables = listOf(ProotCustomEnvironmentVariable("one", "CUSTOM", "bad\u0000value")), + ), + ) + + profiles.forEach { profile -> + assertThrows(IllegalArgumentException::class.java) { + ProotEnvironmentProfileValidator.requireValid(profile) + } + } + } + + @Test + fun `managed overrides wrap commands without shell parsing`() { + assertEquals( + listOf("/usr/bin/env", "DISPLAY=:7", "GALLIUM_DRIVER=", "/bin/bash", "--login"), + ProotEnvironmentResolver.applyManagedOverrides( + command = listOf("/bin/bash", "--login"), + overrides = listOf("DISPLAY=:7", "GALLIUM_DRIVER="), + ), + ) + } + + @Test + fun `profiles saved before managed overrides remain readable`() { + val profile = + ProotEnvironmentProfileCodec.decode( + """{"format":"1","defaults_revision":1,"default_overrides":{},"custom_variables":[]}""", + ) + + assertEquals(emptyMap(), profile.managedOverrides) + } + + @Test + fun `codec rejects profiles larger than 128 KiB`() { + val profile = + ProotEnvironmentProfile( + customVariables = + List(17) { index -> + ProotCustomEnvironmentVariable( + id = "id-$index", + name = "VALUE_$index", + value = "x".repeat(8192), + ) + }, + ) + + assertThrows(IllegalArgumentException::class.java) { + ProotEnvironmentProfileCodec.encode(profile) + } + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt index 36b8276..3af917c 100644 --- a/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt @@ -139,6 +139,151 @@ class ProotTerminalLaunchTest { assertTrue("PULSE_COOKIE=/tmp/.udroid-pulse/cookie" in arguments) } + @Test + fun `software profile wraps the login shell environment`() { + val arguments = + ProotTerminalLaunchBuilder.buildArguments( + linker = "linker64", + prootPath = "proot", + rootfsPath = "rootfs", + guestHome = "/root", + guestShell = "/bin/bash", + launchProfile = + EnvironmentProotLaunchProfile.from(DesktopGraphicsProfile.SOFTWARE), + ) + + assertEquals( + listOf( + "/usr/bin/env", + "LIBGL_ALWAYS_SOFTWARE=1", + "GALLIUM_DRIVER=llvmpipe", + "/bin/bash", + "--login", + ), + arguments.takeLast(5), + ) + } + + @Test + fun `managed override wins over the selected graphics profile`() { + val arguments = + ProotTerminalLaunchBuilder.buildArguments( + linker = "linker64", + prootPath = "proot", + rootfsPath = "rootfs", + guestHome = "/root", + guestShell = "/bin/bash", + launchProfile = + EnvironmentProotLaunchProfile.from(DesktopGraphicsProfile.SOFTWARE), + managedEnvironment = listOf("GALLIUM_DRIVER=custom"), + ).toList() + + assertEquals( + listOf( + "/usr/bin/env", + "LIBGL_ALWAYS_SOFTWARE=1", + "GALLIUM_DRIVER=llvmpipe", + "/usr/bin/env", + "GALLIUM_DRIVER=custom", + "/bin/bash", + "--login", + ), + arguments.takeLast(7), + ) + } + + @Test + fun `zink profile wraps the login shell environment`() { + val arguments = + ProotTerminalLaunchBuilder.buildArguments( + linker = "linker64", + prootPath = "proot", + rootfsPath = "rootfs", + guestHome = "/root", + guestShell = "/bin/bash", + launchProfile = EnvironmentProotLaunchProfile.from(DesktopGraphicsProfile.ZINK), + ) + + assertEquals( + listOf( + "/usr/bin/env", + "MESA_LOADER_DRIVER_OVERRIDE=zink", + "GALLIUM_DRIVER=zink", + "LIBGL_KOPPER_DRI2=true", + "/bin/bash", + "--login", + ), + arguments.takeLast(6), + ) + } + + @Test + fun `terminal receives saved guest variables before locked session and graphics variables`() { + val arguments = + ProotTerminalLaunchBuilder.buildArguments( + linker = "linker64", + prootPath = "proot", + rootfsPath = "rootfs", + guestHome = "/root", + guestShell = "/bin/bash", + x11SocketDirectory = "/data/x11/.X11-unix", + guestEnvironment = + ProotEnvironmentResolver.resolve( + ProotEnvironmentProfile( + defaultOverrides = mapOf("TERM" to "screen-256color"), + customVariables = + listOf( + ProotCustomEnvironmentVariable("custom", "EDITOR", "code --wait"), + ), + ), + ), + launchProfile = + EnvironmentProotLaunchProfile.from(DesktopGraphicsProfile.SOFTWARE), + ) + + assertTrue(arguments.indexOf("HOME=/root") < arguments.indexOf("TERM=screen-256color")) + assertTrue(arguments.indexOf("TERM=screen-256color") < arguments.indexOf("EDITOR=code --wait")) + assertTrue(arguments.indexOf("EDITOR=code --wait") < arguments.indexOf("DISPLAY=:0")) + assertTrue(arguments.indexOf("DISPLAY=:0") < arguments.lastIndexOf("/usr/bin/env")) + } + + @Test + fun `profile bindings and wrapper apply to the complete login shell command`() { + val profile = + object : ProotLaunchProfile { + override fun addBindings(arguments: MutableList) { + arguments += "-b" + arguments += "/data/virgl:/tmp/.virgl" + } + + override fun wrapGuestCommand(command: List): List = + listOf("/usr/bin/env", "GALLIUM_DRIVER=virpipe") + command + } + val arguments = + ProotTerminalLaunchBuilder.buildArguments( + linker = "linker64", + prootPath = "proot", + rootfsPath = "rootfs", + guestHome = "/root", + guestShell = "/bin/bash", + launchProfile = profile, + ) + + assertTrue("/data/virgl:/tmp/.virgl" in arguments) + assertTrue( + arguments.indexOf("/data/virgl:/tmp/.virgl") < arguments.indexOf("--cwd=/root"), + ) + assertEquals( + listOf( + "/usr/bin/env", + "GALLIUM_DRIVER=virpipe", + "/bin/bash", + "--login", + ), + arguments.takeLast(4), + ) + } + @Test fun `proot loader and temporary storage stay app private`() { val environment = diff --git a/app/src/test/java/org/randomcoder/udroid/ui/WorkspaceJourneyTest.kt b/app/src/test/java/org/randomcoder/udroid/ui/WorkspaceJourneyTest.kt index 7dbe4b3..277084e 100644 --- a/app/src/test/java/org/randomcoder/udroid/ui/WorkspaceJourneyTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/ui/WorkspaceJourneyTest.kt @@ -47,6 +47,7 @@ class WorkspaceJourneyTest { assertFalse(journey.destinations.contains(UdroidDestination.SYSTEM)) assertFalse(journey.destinations.contains(UdroidDestination.INSTALL)) assertFalse(journey.destinations.contains(UdroidDestination.DESKTOP)) + assertFalse(journey.destinations.contains(UdroidDestination.ENVIRONMENT)) } @Test @@ -150,6 +151,14 @@ class WorkspaceJourneyTest { NavigationMotion.FORWARD, navigationMotion(UdroidDestination.SYSTEM, UdroidDestination.MOUNTS), ) + assertEquals( + NavigationMotion.FORWARD, + navigationMotion(UdroidDestination.SYSTEM, UdroidDestination.ENVIRONMENT), + ) + assertEquals( + NavigationMotion.BACK, + navigationMotion(UdroidDestination.ENVIRONMENT, UdroidDestination.SYSTEM), + ) assertEquals( NavigationMotion.BACK, navigationMotion(UdroidDestination.MOUNTS, UdroidDestination.SYSTEM), @@ -177,6 +186,7 @@ class WorkspaceJourneyTest { assertEquals(UdroidDestination.DISTROS, journey.destination) assertFalse(journey.destinations.contains(UdroidDestination.MOUNTS)) assertFalse(journey.destinations.contains(UdroidDestination.MOUNT_EDITOR)) + assertFalse(journey.destinations.contains(UdroidDestination.ENVIRONMENT)) } @Test diff --git a/app/src/test/java/org/randomcoder/udroid/virgl/VirglHostLaunchTest.kt b/app/src/test/java/org/randomcoder/udroid/virgl/VirglHostLaunchTest.kt new file mode 100644 index 0000000..69abc5f --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/virgl/VirglHostLaunchTest.kt @@ -0,0 +1,120 @@ +package org.randomcoder.udroid.virgl + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Test + +class VirglHostLaunchTest { + @Test + fun `starts one foreground multi-client server on the private socket`() { + assertEquals( + listOf( + "--no-fork", + "--multi-clients", + "--socket-path", + "/private/graphics/virgl-gles.sock", + ), + VirglHostLaunch.arguments( + File("/private/graphics/virgl-gles.sock"), + VirglHostBackend.NATIVE_GLES, + ), + ) + } + + @Test + fun `starts the ANGLE Vulkan backend on a separate socket`() { + assertEquals( + listOf( + "--no-fork", + "--multi-clients", + "--angle-vulkan", + "--socket-path", + "/private/graphics/virgl-angle-vulkan.sock", + ), + VirglHostLaunch.arguments( + File("/private/graphics/virgl-angle-vulkan.sock"), + VirglHostBackend.ANGLE_VULKAN, + ), + ) + } + + @Test + fun `loads the packaged host libraries`() { + val environment = + VirglHostLaunch.environment( + home = File("/private/files"), + libraryDirectory = File("/private/lib"), + temporaryDirectory = File("/private/cache"), + ) + + assertEquals("/private/lib", environment["LD_LIBRARY_PATH"]) + assertEquals("/private/cache", environment["TMPDIR"]) + assertEquals("/system/bin", environment["PATH"]) + assertEquals(null, environment["VTEST_ANGLE_LIBRARY_PATH"]) + } + + @Test + fun `points the ANGLE backend at only its Vulkan libraries`() { + val environment = + VirglHostLaunch.environment( + home = File("/private/files"), + libraryDirectory = File("/private/virgl-lib"), + temporaryDirectory = File("/private/cache"), + angleLibraryDirectory = File("/private/angle-vulkan-lib"), + ) + + assertEquals("/private/virgl-lib", environment["LD_LIBRARY_PATH"]) + assertEquals( + "/private/angle-vulkan-lib", + environment["VTEST_ANGLE_LIBRARY_PATH"], + ) + } + + @Test + fun `maps only VirGL profiles to their host backend`() { + assertEquals( + VirglHostBackend.NATIVE_GLES, + VirglHostBackend.from( + org.randomcoder.udroid.runtime.DesktopGraphicsProfile.VIRGL, + ), + ) + assertEquals( + VirglHostBackend.ANGLE_VULKAN, + VirglHostBackend.from( + org.randomcoder.udroid.runtime.DesktopGraphicsProfile.VIRGL_ANGLE, + ), + ) + assertEquals( + null, + VirglHostBackend.from( + org.randomcoder.udroid.runtime.DesktopGraphicsProfile.ZINK, + ), + ) + } + + @Test + fun `binds the server socket and selects virpipe for the guest command`() { + val socket = Files.createTempFile("udroid-virgl", ".sock").toFile() + val profile = VirglProotLaunchProfile(socket) + val bindings = mutableListOf() + + profile.addBindings(bindings) + + assertEquals( + listOf("-b", "${socket.absolutePath}:/tmp/.virgl_test"), + bindings, + ) + assertEquals( + listOf( + "/usr/bin/env", + "-u", + "LIBGL_ALWAYS_SOFTWARE", + "GALLIUM_DRIVER=virpipe", + "/usr/bin/glxinfo", + "-B", + ), + profile.wrapGuestCommand(listOf("/usr/bin/glxinfo", "-B")), + ) + } +}