diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6e28725..67e1c1a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -162,11 +162,14 @@ 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"))
+ inputs.dir(runtimeRoot.resolve("venus-mesa"))
doLast {
fun ByteArray.containsSequence(needle: ByteArray): Boolean {
@@ -184,7 +187,14 @@ val verifyGfxstreamRuntimeAssets by
return false
}
- val bundleNames = listOf("gfxstream-host", "gfxstream-guest")
+ val bundleNames =
+ listOf(
+ "gfxstream-host",
+ "gfxstream-guest",
+ "virgl-host",
+ "virgl-angle-vulkan",
+ "venus-mesa",
+ )
val manifests =
bundleNames.associateWith { bundleName ->
val bundle = runtimeRoot.resolve(bundleName)
@@ -238,5 +248,5 @@ val verifyGfxstreamRuntimeAssets by
}
tasks.matching { it.name == "preBuild" }.configureEach {
- dependsOn(verifyGfxstreamRuntimeAssets)
+ dependsOn(verifyGraphicsRuntimeAssets)
}
diff --git a/app/src/dev/java/org/randomcoder/udroid/gfxstream/GfxstreamPresenterProbeActivity.kt b/app/src/dev/java/org/randomcoder/udroid/gfxstream/GfxstreamPresenterProbeActivity.kt
index 660c4ad..2f12dac 100644
--- a/app/src/dev/java/org/randomcoder/udroid/gfxstream/GfxstreamPresenterProbeActivity.kt
+++ b/app/src/dev/java/org/randomcoder/udroid/gfxstream/GfxstreamPresenterProbeActivity.kt
@@ -58,7 +58,11 @@ class GfxstreamPresenterProbeActivity : Activity() {
)
if (externalProducer) {
hostController =
- GfxstreamHostController(this).also {
+ GfxstreamHostController(
+ this,
+ (application as UdroidApplication).journal,
+ PROBE_BOOT_ID,
+ ).also {
it.startAsync(
presenter.transportSocketFile(),
contractTrace = contractTrace,
diff --git a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/MANIFEST.properties b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/MANIFEST.properties
new file mode 100644
index 0000000..0c07667
--- /dev/null
+++ b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/MANIFEST.properties
@@ -0,0 +1,14 @@
+format=1
+runtime=26.1.5-jammy
+abi=arm64-v8a
+source_package=mesa
+mesa_commit=6a02618ccf6c5651ecb9cccbde571eb61fd73592
+lib/dri/zink_dri.so.sha256=a85c32422ab9c0b1b98495fc2274d8bfc7c6892f02496d60f884023ca45ccc41
+lib/libEGL_mesa.so.0.sha256=b6b2e58a5e2b0b1aa6687891272625c89d727166b326cdf67cc2f040ae1cc484
+lib/libGLX_mesa.so.0.sha256=36bad44d10cdb8610cbdfce7c532c4520acd4f1a9f9e92943e66606cc530b2fb
+lib/libgallium-26.1.5.so.sha256=1a190093d84f64372c9ae1b001b4adc0d226b308486dc21d8565c53f2a1df75b
+lib/libvulkan_virtio.so.sha256=82b57a2c7b7ea71e2cef92919a9529c5ff2dcda9bc7f53ed1bdf38bc81ca6e47
+share/doc/license.rst.sha256=0d1a0472ecc81830e75c20d59b0ea02841e3db21255e0ebad97ab682c54d6615
+share/drirc.d/00-mesa-defaults.conf.sha256=1406e018d5a83dacc9dceb5cecdc47410ad7f31a573390ec8c41c729e23c038a
+share/glvnd/egl_vendor.d/50_mesa.json.sha256=cd3881df4227c13ca7f9cfe6c78820f21d5f7a00a41b299214f6f827e63be369
+share/vulkan/icd.d/virtio_icd.aarch64.json.sha256=9459d4356818fff45f946ba1e623eec42db4bdb402b10e9c9d85c0a9560ba011
diff --git a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/dri/zink_dri.so b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/dri/zink_dri.so
new file mode 100644
index 0000000..841b216
Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/dri/zink_dri.so differ
diff --git a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libEGL_mesa.so.0 b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libEGL_mesa.so.0
new file mode 100644
index 0000000..16a2816
Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libEGL_mesa.so.0 differ
diff --git a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libGLX_mesa.so.0 b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libGLX_mesa.so.0
new file mode 100644
index 0000000..5f028a8
Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libGLX_mesa.so.0 differ
diff --git a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libgallium-26.1.5.so b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libgallium-26.1.5.so
new file mode 100644
index 0000000..680fbed
Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libgallium-26.1.5.so differ
diff --git a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libvulkan_virtio.so b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libvulkan_virtio.so
new file mode 100644
index 0000000..01002ab
Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libvulkan_virtio.so differ
diff --git a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/doc/license.rst b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/doc/license.rst
new file mode 100644
index 0000000..704f816
--- /dev/null
+++ b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/doc/license.rst
@@ -0,0 +1,50 @@
+License and Copyright
+=====================
+
+Disclaimer
+----------
+
+Mesa implements various APIs, including `OpenGL`_, `OpenGL ES`_,
+`Vulkan`_ and `OpenCL`_. Even though Mesa implements these APIs, the
+implementation isn't formally conformant on all combinations of drivers
+and hardware. `Khronos`_ maintains lists of conformant implementations
+for each of their APIs, as well as `trademark details`_.
+
+This project is called *Mesa*, *Mesa3D*, or *The Mesa 3-D graphics library*.
+
+.. _OpenGL: https://www.opengl.org/
+.. _OpenGL ES: https://www.khronos.org/opengles/
+.. _Vulkan: https://www.vulkan.org/
+.. _OpenCL: https://www.khronos.org/opencl/
+.. _Khronos: https://www.khronos.org/
+.. _trademark details: https://www.khronos.org/legal/trademarks/
+
+License / Copyright Information
+-------------------------------
+
+The Mesa distribution consists of several components. Different
+copyrights and licenses apply to different components. For example, the
+GLX client code uses the SGI Free Software License B, and some of the
+Mesa device drivers are copyrighted by their authors. See below for a
+list of Mesa's main components and the license for each.
+
+The core Mesa library is licensed according to the terms of the MIT
+license.
+
+Most of the Mesa code is licensed under MIT license,
+but individual files may have their own licenses.
+
+You may find all the licenses used within this project in the licenses/ directory.
+
+Attention, Contributors
+-----------------------
+
+When contributing to the Mesa project you must agree to the licensing
+terms of the component to which you're contributing. The following
+section lists the primary components of the Mesa distribution and their
+respective licenses.
+
+Mesa Component Licenses
+-----------------------
+
+In general, consult each source file SPDX identifier for license terms.
diff --git a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/drirc.d/00-mesa-defaults.conf b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/drirc.d/00-mesa-defaults.conf
new file mode 100644
index 0000000..4b48e4b
--- /dev/null
+++ b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/drirc.d/00-mesa-defaults.conf
@@ -0,0 +1,1567 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/glvnd/egl_vendor.d/50_mesa.json b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/glvnd/egl_vendor.d/50_mesa.json
new file mode 100644
index 0000000..8aaaa10
--- /dev/null
+++ b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/glvnd/egl_vendor.d/50_mesa.json
@@ -0,0 +1,6 @@
+{
+ "file_format_version" : "1.0.0",
+ "ICD" : {
+ "library_path" : "libEGL_mesa.so.0"
+ }
+}
diff --git a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/vulkan/icd.d/virtio_icd.aarch64.json b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/vulkan/icd.d/virtio_icd.aarch64.json
new file mode 100644
index 0000000..0b16c59
--- /dev/null
+++ b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/share/vulkan/icd.d/virtio_icd.aarch64.json
@@ -0,0 +1,8 @@
+{
+ "ICD": {
+ "api_version": "1.4.354",
+ "library_arch": "64",
+ "library_path": "/opt/udroid/venus-mesa/lib/libvulkan_virtio.so"
+ },
+ "file_format_version": "1.0.1"
+}
\ No newline at end of file
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..cbb266a
--- /dev/null
+++ b/app/src/main/assets/runtime/arm64-v8a/virgl-host/MANIFEST.properties
@@ -0,0 +1,13 @@
+format=1
+runtime=1.3.0-6
+abi=arm64-v8a
+source_package=virglrenderer-android
+termux_packages_commit=978cdac5bdeb47f6061996240b65fc4e4dff11df
+source_deb_sha256=847839a8cac5a349052e396f28bfe68061791e34901d4413b490e4555701399f
+bin/virgl_test_server_android.sha256=c4fa02e432f2edbf974c8bf85922b6d1f7418be633bce443e5b1893c312273aa
+libexec/virgl_render_server.sha256=e93c0082b7c82a8ec4c54c621e9d4c0e12fccbc7049f77b92398126199418c9e
+lib/libepoxy.so.sha256=7b50fe1ef66cfea354f398523169bdb4ac8ec3b2715ac77073540bd5a1b3075f
+lib/libvirglrenderer.so.sha256=15afb2811333f6947e29ebb498738bc9523708aeb05dbc9ae6630a708666798c
+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..9e36f84
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/libexec/virgl_render_server b/app/src/main/assets/runtime/arm64-v8a/virgl-host/libexec/virgl_render_server
new file mode 100755
index 0000000..1609ca0
Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/virgl-host/libexec/virgl_render_server 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..f448cef 100644
--- a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt
+++ b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt
@@ -60,11 +60,14 @@ 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.ProotMountResolver
import org.randomcoder.udroid.runtime.RuntimePhase
import org.randomcoder.udroid.runtime.RuntimeSnapshot
import org.randomcoder.udroid.runtime.RuntimeSupervisorService
+import org.randomcoder.udroid.runtime.VirglServerMode
import org.randomcoder.udroid.ui.UdroidApp
import org.randomcoder.udroid.ui.UdroidCanvas
import org.randomcoder.udroid.ui.UdroidDarkCanvas
@@ -266,6 +269,7 @@ class MainActivity : ComponentActivity() {
onCompositingChanged = { updateCompositing(it) },
onTouchScaleChanged = { updateTouchScale(it) },
onGraphicsProfileChanged = { updateGraphicsProfile(it) },
+ onVirglServerModeChanged = { updateVirglServerMode(it) },
onAudioOutputChanged = { updateAudioOutput(it) },
onMicrophoneChanged = { updateMicrophone(it) },
onStartDesktop = { startSelectedDesktop() },
@@ -839,6 +843,9 @@ class MainActivity : ComponentActivity() {
runCatching { audioConfigurationStore.remove(rootfsName) }
.exceptionOrNull()
?.let { cleanupWarnings += it.message ?: "audio settings" }
+ runCatching { ProotMountResolver.removeRuntime(this@MainActivity, rootfsName) }
+ .exceptionOrNull()
+ ?.let { cleanupWarnings += it.message ?: "runtime files" }
runCatching {
LinuxApplicationShortcutPublisher(this@MainActivity)
.disableForRootfs(rootfsName)
@@ -846,6 +853,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" }
@@ -1021,6 +1031,14 @@ class MainActivity : ComponentActivity() {
)
}
+ private fun updateVirglServerMode(mode: VirglServerMode) {
+ val rootfsName = selectedSystemRootfsName ?: return
+ saveDesktopConfiguration(
+ rootfsName,
+ desktopConfiguration.copy(virglServerMode = mode),
+ )
+ }
+
private fun loadAudioConfiguration(rootfsName: String) {
runCatching { audioConfigurationStore.load(rootfsName) }
.onSuccess {
diff --git a/app/src/main/java/org/randomcoder/udroid/audio/AudioServerController.kt b/app/src/main/java/org/randomcoder/udroid/audio/AudioServerController.kt
index 5ba6e8e..71ddda5 100644
--- a/app/src/main/java/org/randomcoder/udroid/audio/AudioServerController.kt
+++ b/app/src/main/java/org/randomcoder/udroid/audio/AudioServerController.kt
@@ -103,6 +103,12 @@ class AudioServerController(
"--dl-search-path=${runtime.moduleDirectory.absolutePath}",
"--file=${configurationFile.absolutePath}",
)
+ journal.appendCommand(
+ component = "audio",
+ command = command,
+ bootId = bootId,
+ fields = mapOf("working_directory" to appContext.filesDir.absolutePath),
+ )
val process =
ProcessBuilder(command)
.directory(appContext.filesDir)
diff --git a/app/src/main/java/org/randomcoder/udroid/gfxstream/GfxstreamHostController.kt b/app/src/main/java/org/randomcoder/udroid/gfxstream/GfxstreamHostController.kt
index 6a1400b..2acaabd 100644
--- a/app/src/main/java/org/randomcoder/udroid/gfxstream/GfxstreamHostController.kt
+++ b/app/src/main/java/org/randomcoder/udroid/gfxstream/GfxstreamHostController.kt
@@ -2,6 +2,7 @@ package org.randomcoder.udroid.gfxstream
import android.content.Context
import org.randomcoder.udroid.runtime.AndroidExecutableCommand
+import org.randomcoder.udroid.runtime.EventJournal
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
@@ -60,6 +61,8 @@ internal object GfxstreamHostLaunch {
/** Owns one optional gfxstream host process for a selected runtime consumer. */
class GfxstreamHostController(
context: Context,
+ private val journal: EventJournal,
+ private val bootId: String?,
private val onUnexpectedExit: (GfxstreamHostSnapshot) -> Unit = {},
) : AutoCloseable {
private val appContext = context.applicationContext
@@ -106,6 +109,12 @@ class GfxstreamHostController(
runtime.executable,
*GfxstreamHostLaunch.arguments(gpuSocket, presenterSocket).toTypedArray(),
)
+ journal.appendCommand(
+ component = "gfxstream",
+ command = command,
+ bootId = bootId,
+ fields = mapOf("working_directory" to graphicsDirectory.absolutePath),
+ )
val launched =
ProcessBuilder(command)
.directory(graphicsDirectory)
diff --git a/app/src/main/java/org/randomcoder/udroid/install/AndroidRootfsRuntime.kt b/app/src/main/java/org/randomcoder/udroid/install/AndroidRootfsRuntime.kt
index 8d55613..da46741 100644
--- a/app/src/main/java/org/randomcoder/udroid/install/AndroidRootfsRuntime.kt
+++ b/app/src/main/java/org/randomcoder/udroid/install/AndroidRootfsRuntime.kt
@@ -5,8 +5,12 @@ import android.os.Build
import android.os.StatFs
import org.randomcoder.udroid.runtime.AndroidExecutableCommand
import org.randomcoder.udroid.runtime.ANDROID_PROOT_BIND_MOUNTS
+import org.randomcoder.udroid.runtime.ProotMountProfile
+import org.randomcoder.udroid.runtime.ProotMountResolver
import org.randomcoder.udroid.runtime.ProotPathContract
+import org.randomcoder.udroid.runtime.ResolvedProotMount
import org.randomcoder.udroid.runtime.addAndroidProotBindMounts
+import org.randomcoder.udroid.runtime.addProotBindMounts
import org.tukaani.xz.XZInputStream
import java.io.BufferedReader
import java.io.File
@@ -84,6 +88,7 @@ class ProotTarExtractor(
private val stripComponents: Int = 0,
private val excludeOciWhiteouts: Boolean = false,
private val onDiagnostic: (String) -> Unit = {},
+ private val onCommand: (List) -> Unit = {},
) : RootfsExtractor {
override fun extract(
archive: File,
@@ -132,6 +137,7 @@ class ProotTarExtractor(
File(context.cacheDir, "proot").apply {
check(mkdirs() || isDirectory) { "Could not create PRoot temporary storage" }
}
+ onCommand(command)
val process =
ProcessBuilder(command)
.directory(context.filesDir)
@@ -307,12 +313,6 @@ class AndroidRootfsConfigurator : RootfsConfigurator {
"Could not prepare /$it"
}
}
- File(rootfs, "proc").apply {
- check(setReadable(true, true) && setWritable(true, true) && setExecutable(true, true)) {
- "Could not prepare rootfs /proc compatibility directory"
- }
- }
-
replaceFile(
File(rootfs, "etc/hosts"),
"""
@@ -325,15 +325,6 @@ class AndroidRootfsConfigurator : RootfsConfigurator {
File(rootfs, "etc/resolv.conf"),
"nameserver 1.1.1.1\nnameserver 8.8.8.8\n",
)
- replaceFile(
- File(rootfs, "proc/.version"),
- "Linux version 5.4.0-udroid-faked (udroid@android)\n",
- )
- replaceFile(File(rootfs, "proc/.uptime"), "0.00 0.00\n")
- replaceFile(File(rootfs, "proc/.loadavg"), "0.00 0.00 0.00 1/1 1\n")
- replaceFile(File(rootfs, "proc/.stat"), "cpu 1 0 1 1 0 0 0 0 0 0\n")
- replaceFile(File(rootfs, "proc/.vmstat"), "nr_free_pages 0\n")
-
replaceFile(
File(rootfs, "etc/profile.d/udroid.sh"),
"""
@@ -403,6 +394,7 @@ class AndroidRootfsConfigurator : RootfsConfigurator {
class ProotRootfsHealthCheck(
private val context: Context,
private val runtime: ProotRuntime,
+ private val onCommand: (List) -> Unit = {},
) : RootfsHealthCheck {
override fun check(rootfs: File) {
val shell =
@@ -413,16 +405,23 @@ class ProotRootfsHealthCheck(
check(File(rootfs, "usr/bin/env").let { it.isFile || it.isSymbolicLink() }) {
"Extracted rootfs has no /usr/bin/env"
}
- val process =
- ProcessBuilder(
- AndroidExecutableCommand.create(
- runtime.executable,
- *buildArguments(
- rootfsPath = ProotPathContract.rootfsPath(context, rootfs),
- shellPath = "/${shell.relativeTo(rootfs).path}",
- ),
+ val command =
+ AndroidExecutableCommand.create(
+ runtime.executable,
+ *buildArguments(
+ rootfsPath = ProotPathContract.rootfsPath(context, rootfs),
+ shellPath = "/${shell.relativeTo(rootfs).path}",
+ mounts =
+ ProotMountResolver.resolve(
+ context = context,
+ rootfs = rootfs,
+ profile = ProotMountProfile(),
+ ),
),
- ).apply {
+ )
+ onCommand(command)
+ val process =
+ ProcessBuilder(command).apply {
directory(context.filesDir)
redirectErrorStream(true)
environment().remove("LD_PRELOAD")
@@ -438,12 +437,15 @@ class ProotRootfsHealthCheck(
}.start()
val output = process.inputStream.bufferedReader().use { it.readText().trim() }
val exitCode = process.waitFor()
- check(exitCode == 0) {
- if (output.isBlank()) {
- "Rootfs health check failed with exit code $exitCode"
- } else {
- "Rootfs health check failed: ${output.lineSequence().last()}"
- }
+ if (exitCode != 0) {
+ runCatching { ProotMountResolver.removeRuntime(context, rootfs.name) }
+ error(
+ if (output.isBlank()) {
+ "Rootfs health check failed with exit code $exitCode"
+ } else {
+ "Rootfs health check failed: ${output.lineSequence().last()}"
+ },
+ )
}
}
@@ -454,13 +456,14 @@ class ProotRootfsHealthCheck(
internal fun buildArguments(
rootfsPath: String,
shellPath: String,
+ mounts: List = ProotMountResolver.defaults(),
): Array =
buildList {
add("--link2symlink")
add("--kill-on-exit")
add("--root-id")
add("--rootfs=$rootfsPath")
- addAndroidProotBindMounts()
+ addProotBindMounts(mounts)
add("--cwd=/")
add("/usr/bin/env")
add("-i")
diff --git a/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt b/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt
index cf0df7c..fd7cb62 100644
--- a/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt
+++ b/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt
@@ -371,7 +371,9 @@ class InstallerService : Service() {
val progress = OciForegroundProgress(work)
try {
val result =
- OciRootfsInstaller(this).install(
+ OciRootfsInstaller(this) { command ->
+ app.journal.appendCommand("installer", command, work.operationId)
+ }.install(
request =
OciRootfsInstallRequest(
reference = work.reference,
@@ -469,9 +471,15 @@ class InstallerService : Service() {
bootId = operationId,
)
},
+ onCommand = { command ->
+ app.journal.appendCommand("installer", command, operationId)
+ },
),
configurator = AndroidRootfsConfigurator(),
- healthCheck = ProotRootfsHealthCheck(this, prootRuntime),
+ healthCheck =
+ ProotRootfsHealthCheck(this, prootRuntime) { command ->
+ app.journal.appendCommand("installer", command, operationId)
+ },
)
val result =
pipeline.execute(
diff --git a/app/src/main/java/org/randomcoder/udroid/oci/OciLayerArchive.kt b/app/src/main/java/org/randomcoder/udroid/oci/OciLayerArchive.kt
index 9940b4d..ace2db7 100644
--- a/app/src/main/java/org/randomcoder/udroid/oci/OciLayerArchive.kt
+++ b/app/src/main/java/org/randomcoder/udroid/oci/OciLayerArchive.kt
@@ -288,6 +288,7 @@ class OciLayerRootfsAssembler(
private val runtime: ProotRuntime,
private val scanner: TarEntryNameScanner = TarEntryNameScanner(),
private val whiteoutApplier: OciWhiteoutApplier = OciWhiteoutApplier(),
+ private val onCommand: (List) -> Unit = {},
) {
fun assemble(
layers: List,
@@ -312,6 +313,7 @@ class OciLayerRootfsAssembler(
context = context,
runtime = runtime,
excludeOciWhiteouts = true,
+ onCommand = onCommand,
).extract(layer.file, destination) { completed, total ->
onLayerProgress(index, layers.size, completed, total)
}
diff --git a/app/src/main/java/org/randomcoder/udroid/oci/OciRootfsInstaller.kt b/app/src/main/java/org/randomcoder/udroid/oci/OciRootfsInstaller.kt
index 47caee9..ee96e68 100644
--- a/app/src/main/java/org/randomcoder/udroid/oci/OciRootfsInstaller.kt
+++ b/app/src/main/java/org/randomcoder/udroid/oci/OciRootfsInstaller.kt
@@ -48,7 +48,10 @@ data class OciInstallEvent(
val resumed: Boolean = false,
)
-class OciRootfsInstaller(private val context: Context) {
+class OciRootfsInstaller(
+ private val context: Context,
+ private val onCommand: (List) -> Unit = {},
+) {
fun install(
request: OciRootfsInstallRequest,
onEvent: (OciInstallEvent) -> Unit = {},
@@ -129,7 +132,7 @@ class OciRootfsInstaller(private val context: Context) {
"Applying ${pull.layers.size} verified filesystem layer(s)",
),
)
- OciLayerRootfsAssembler(context, runtime).assemble(
+ OciLayerRootfsAssembler(context, runtime, onCommand = onCommand).assemble(
layers = pull.layers,
destination = staging,
) { index, count, completed, total ->
@@ -157,7 +160,7 @@ class OciRootfsInstaller(private val context: Context) {
"Running the first-boot health probe",
),
)
- ProotRootfsHealthCheck(context, runtime).check(staging)
+ ProotRootfsHealthCheck(context, runtime, onCommand).check(staging)
writeMarker(
File(staging, RootfsInstallationPipeline.READY_MARKER),
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..3f28d53 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,11 @@ enum class DesktopGraphicsProfile(
val storageValue: String,
) {
STANDARD("standard"),
+ SOFTWARE("software"),
+ ZINK("zink"),
+ VIRGL("virgl"),
+ VIRGL_ANGLE("virgl-angle"),
+ VENUS_EXPERIMENTAL("venus-experimental"),
GFXSTREAM_EXPERIMENTAL("gfxstream-experimental"),
;
@@ -49,12 +54,39 @@ enum class DesktopGraphicsProfile(
}
}
+enum class VirglServerMode(
+ val storageValue: String,
+) {
+ AUTOMATIC("automatic"),
+ COMPATIBILITY("compatibility"),
+ MULTI_CLIENT("multi-client"),
+ ;
+
+ val multiClients: Boolean
+ get() = this == MULTI_CLIENT
+
+ companion object {
+ fun fromStorage(value: String?): VirglServerMode =
+ entries.firstOrNull { it.storageValue == value } ?: AUTOMATIC
+ }
+}
+
data class DesktopConfiguration(
val environmentId: String?,
val compositingEnabled: Boolean,
val touchScaleEnabled: Boolean,
val graphicsProfile: DesktopGraphicsProfile = DesktopGraphicsProfile.STANDARD,
-)
+ val virglServerMode: VirglServerMode = VirglServerMode.AUTOMATIC,
+) {
+ val supportsCompositedDesktop: Boolean
+ get() =
+ (graphicsProfile != DesktopGraphicsProfile.VIRGL &&
+ graphicsProfile != DesktopGraphicsProfile.VIRGL_ANGLE) ||
+ virglServerMode == VirglServerMode.MULTI_CLIENT
+
+ val effectiveCompositingEnabled: Boolean
+ get() = compositingEnabled && supportsCompositedDesktop
+}
enum class DesktopSessionPhase {
STOPPED,
@@ -112,6 +144,10 @@ class DesktopConfigurationStore(context: Context) {
DesktopGraphicsProfile.fromStorage(
preferences.getString(key(rootfsName, KEY_GRAPHICS_PROFILE), null),
),
+ virglServerMode =
+ VirglServerMode.fromStorage(
+ preferences.getString(key(rootfsName, KEY_VIRGL_SERVER_MODE), null),
+ ),
)
}
@@ -137,6 +173,9 @@ class DesktopConfigurationStore(context: Context) {
).putString(
key(rootfsName, KEY_GRAPHICS_PROFILE),
configuration.graphicsProfile.storageValue,
+ ).putString(
+ key(rootfsName, KEY_VIRGL_SERVER_MODE),
+ configuration.virglServerMode.storageValue,
).commit(),
) {
"Could not save desktop settings for $rootfsName"
@@ -144,6 +183,16 @@ class DesktopConfigurationStore(context: Context) {
return configuration
}
+ fun loadGraphicsProfile(rootfsName: String): DesktopGraphicsProfile =
+ DesktopGraphicsProfile.fromStorage(
+ preferences.getString(key(rootfsName, KEY_GRAPHICS_PROFILE), null),
+ )
+
+ fun loadVirglServerMode(rootfsName: String): VirglServerMode =
+ VirglServerMode.fromStorage(
+ preferences.getString(key(rootfsName, KEY_VIRGL_SERVER_MODE), null),
+ )
+
fun remove(rootfsName: String) {
val prefix = "$rootfsName:"
val editor = preferences.edit()
@@ -172,6 +221,7 @@ class DesktopConfigurationStore(context: Context) {
const val KEY_COMPOSITING = "compositing"
const val KEY_TOUCH_SCALE = "touch-scale"
const val KEY_GRAPHICS_PROFILE = "graphics-profile"
+ const val KEY_VIRGL_SERVER_MODE = "virgl-server-mode"
}
}
diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/EventJournal.kt b/app/src/main/java/org/randomcoder/udroid/runtime/EventJournal.kt
index 10b96e6..f27c9fa 100644
--- a/app/src/main/java/org/randomcoder/udroid/runtime/EventJournal.kt
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/EventJournal.kt
@@ -38,6 +38,22 @@ class EventJournal(context: Context) {
activeFile.appendText(payload.toString() + "\n")
}
+ fun appendCommand(
+ component: String,
+ command: List,
+ bootId: String?,
+ fields: Map = emptyMap(),
+ ) {
+ append(
+ component = component,
+ severity = "debug",
+ event = "command_launch",
+ message = formatCommandForLog(command),
+ bootId = bootId,
+ fields = fields,
+ )
+ }
+
@Synchronized
fun tail(limit: Int = 80): List {
if (!activeFile.exists()) return emptyList()
@@ -54,3 +70,9 @@ class EventJournal(context: Context) {
const val MAX_BYTES = 1024L * 1024L
}
}
+
+internal fun formatCommandForLog(command: List): String {
+ return command.joinToString(" ") { argument ->
+ "'${argument.replace("'", "'\"'\"'")}'"
+ }
+}
diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/GraphicsProfileCompatibility.kt b/app/src/main/java/org/randomcoder/udroid/runtime/GraphicsProfileCompatibility.kt
new file mode 100644
index 0000000..f378dd8
--- /dev/null
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/GraphicsProfileCompatibility.kt
@@ -0,0 +1,171 @@
+package org.randomcoder.udroid.runtime
+
+import android.app.ActivityManager
+import android.content.Context
+import android.content.pm.PackageManager
+import android.hardware.HardwareBuffer
+import android.os.Build
+import org.randomcoder.udroid.virgl.VenusGuestRuntimeInstaller
+import java.io.File
+
+data class GraphicsProfileSupport(
+ val available: Boolean,
+ val reason: String? = null,
+)
+
+internal data class GraphicsDeviceSupport(
+ val arm64: Boolean,
+ val openGlEsVersion: Int,
+ val vulkanVersion: Int,
+ val hardwareBuffer: Boolean,
+)
+
+internal data class GraphicsGuestSupport(
+ val software: Boolean,
+ val zink: Boolean,
+ val vulkanIcd: Boolean,
+ val virgl: Boolean,
+ val venus: Boolean,
+)
+
+object GraphicsProfileCompatibilityProbe {
+ fun run(
+ context: Context,
+ rootfs: File,
+ ): Map =
+ classify(
+ device = deviceSupport(context),
+ guest = guestSupport(rootfs),
+ )
+
+ internal fun classify(
+ device: GraphicsDeviceSupport,
+ guest: GraphicsGuestSupport,
+ ): Map =
+ mapOf(
+ DesktopGraphicsProfile.STANDARD to supported(),
+ DesktopGraphicsProfile.SOFTWARE to
+ require(guest.software, "Mesa software rendering is not installed in Linux"),
+ DesktopGraphicsProfile.ZINK to
+ require(
+ guest.zink && guest.vulkanIcd,
+ if (guest.zink) {
+ "No Vulkan driver is installed in Linux"
+ } else {
+ "Mesa Zink is not installed in Linux"
+ },
+ ),
+ DesktopGraphicsProfile.VIRGL to
+ firstFailure(
+ device.arm64 to "VirGL is packaged only for arm64 devices",
+ isOpenGlEs3(device.openGlEsVersion) to "VirGL requires Android OpenGL ES 3.0",
+ guest.virgl to "Mesa VirGL support is not installed in Linux",
+ ),
+ DesktopGraphicsProfile.VIRGL_ANGLE to
+ firstFailure(
+ device.arm64 to "VirGL + ANGLE is packaged only for arm64 devices",
+ isVulkan11(device.vulkanVersion) to
+ "VirGL + ANGLE requires Android Vulkan 1.1",
+ guest.virgl to "Mesa VirGL support is not installed in Linux",
+ ),
+ DesktopGraphicsProfile.VENUS_EXPERIMENTAL to
+ firstFailure(
+ device.arm64 to "Venus is packaged only for arm64 devices",
+ isVulkan11(device.vulkanVersion) to "Venus requires Android Vulkan 1.1",
+ device.hardwareBuffer to "This device cannot allocate GPU hardware buffers",
+ guest.venus to "A compatible Mesa Venus driver is not available in Linux",
+ ),
+ DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL to
+ require(GFXSTREAM_PROFILE_ENABLED, "gfxstream is not enabled in this build"),
+ )
+
+ private fun deviceSupport(context: Context): GraphicsDeviceSupport {
+ val packageManager = context.packageManager
+ val vulkanVersion =
+ packageManager.systemAvailableFeatures
+ .firstOrNull { it.name == PackageManager.FEATURE_VULKAN_HARDWARE_VERSION }
+ ?.version ?: 0
+ val openGlEsVersion =
+ context.getSystemService(ActivityManager::class.java)
+ ?.deviceConfigurationInfo
+ ?.reqGlEsVersion ?: 0
+ val hardwareBuffer =
+ runCatching {
+ HardwareBuffer.create(
+ 4,
+ 4,
+ HardwareBuffer.RGBA_8888,
+ 1,
+ HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or
+ HardwareBuffer.USAGE_GPU_COLOR_OUTPUT,
+ ).close()
+ }.isSuccess
+ return GraphicsDeviceSupport(
+ arm64 = "arm64-v8a" in Build.SUPPORTED_ABIS,
+ openGlEsVersion = openGlEsVersion,
+ vulkanVersion = vulkanVersion,
+ hardwareBuffer = hardwareBuffer,
+ )
+ }
+
+ private fun guestSupport(rootfs: File): GraphicsGuestSupport {
+ val driDirectories =
+ listOf(
+ "usr/lib/aarch64-linux-gnu/dri",
+ "usr/lib64/dri",
+ "usr/lib/dri",
+ ).map(rootfs::resolve)
+ val icdDirectories =
+ listOf(
+ "usr/share/vulkan/icd.d",
+ "etc/vulkan/icd.d",
+ ).map(rootfs::resolve)
+ val libraryDirectories =
+ listOf(
+ "usr/lib/aarch64-linux-gnu",
+ "usr/lib64",
+ "usr/lib",
+ ).map(rootfs::resolve)
+ val hasDriDriver = { name: String -> driDirectories.any { File(it, name).isFile } }
+ val icds =
+ icdDirectories.flatMap { directory ->
+ directory.listFiles()
+ ?.filter { file -> file.isFile && file.extension == "json" }
+ .orEmpty()
+ }
+ val systemVenus =
+ icds.any { it.name.contains("virtio", ignoreCase = true) } &&
+ libraryDirectories.any { directory ->
+ directory.listFiles()
+ ?.any { file ->
+ file.isFile && file.name.startsWith("libvulkan_virtio.so")
+ } == true
+ }
+ return GraphicsGuestSupport(
+ software = hasDriDriver("swrast_dri.so"),
+ zink = hasDriDriver("zink_dri.so"),
+ vulkanIcd = icds.isNotEmpty(),
+ virgl = hasDriDriver("virtio_gpu_dri.so"),
+ venus = VenusGuestRuntimeInstaller.requiresBundledMesa(rootfs) || systemVenus,
+ )
+ }
+
+ private fun firstFailure(vararg checks: Pair): GraphicsProfileSupport =
+ checks.firstOrNull { !it.first }
+ ?.let { GraphicsProfileSupport(false, it.second) }
+ ?: supported()
+
+ private fun require(
+ condition: Boolean,
+ reason: String,
+ ): GraphicsProfileSupport =
+ if (condition) supported() else GraphicsProfileSupport(false, reason)
+
+ private fun supported() = GraphicsProfileSupport(true)
+
+ private fun isOpenGlEs3(version: Int): Boolean = version >= 0x00030000
+
+ private fun isVulkan11(version: Int): Boolean =
+ (version ushr 22) > 1 ||
+ (version ushr 22) == 1 && ((version ushr 12) and 0x3ff) >= 1
+}
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..174e533 100644
--- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt
@@ -32,6 +32,8 @@ object ProotApplicationLaunchBuilder {
?: guestHome
val mounts =
ProotMountResolver.resolve(
+ context = context,
+ rootfs = rootfs,
profile = ProotMountProfileStore(context).load(rootfs.name),
sessionMounts =
ProotMountResolver.sessionMounts(
@@ -39,6 +41,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 +57,8 @@ object ProotApplicationLaunchBuilder {
audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath,
launchProfile = launchProfile,
mounts = mounts,
+ guestEnvironment = guestEnvironment,
+ managedEnvironment = managedEnvironment,
)
val temporaryDirectory =
File(context.cacheDir, "proot").apply {
@@ -90,6 +98,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 +117,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 +126,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..be5d0e7 100644
--- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt
@@ -24,6 +24,8 @@ object ProotDesktopLaunchBuilder {
val guestHome = if (File(rootfs, "root").isDirectory) "/root" else "/"
val mounts =
ProotMountResolver.resolve(
+ context = context,
+ rootfs = rootfs,
profile = ProotMountProfileStore(context).load(rootfs.name),
sessionMounts =
ProotMountResolver.sessionMounts(
@@ -31,6 +33,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 +48,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 +93,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 +111,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}")
@@ -126,11 +135,13 @@ object ProotDesktopLaunchBuilder {
}
add("/bin/sh")
add("-lc")
- add(compositorScript(environment.kind, configuration.compositingEnabled))
+ add(compositorScript(environment.kind, configuration.effectiveCompositingEnabled))
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(
@@ -139,14 +150,13 @@ object ProotDesktopLaunchBuilder {
): String {
val value = enabled.toString()
if (kind == DesktopEnvironmentKind.XFCE) {
- return "\"\$@\" & desktop_pid=\$!; " +
- "if command -v xfconf-query >/dev/null 2>&1; then " +
- "sleep 1; attempt=0; " +
+ return "if command -v xfconf-query >/dev/null 2>&1; then " +
+ "attempt=0; " +
"while [ \"\$attempt\" -lt 5 ]; do " +
"xfconf-query -c xfwm4 -p /general/use_compositing " +
- "-n -t bool -s $value >/dev/null 2>&1 || true; " +
+ "-n -t bool -s $value >/dev/null 2>&1 && break; " +
"attempt=\$((attempt + 1)); sleep 0.2; " +
- "done; fi; wait \"\$desktop_pid\""
+ "done; fi; exec \"\$@\""
}
val configuration =
when (kind) {
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..ef0db6d
--- /dev/null
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotEnvironmentProfiles.kt
@@ -0,0 +1,296 @@
+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",
+ "VN_DEBUG",
+ "MESA_VK_WSI_DEBUG",
+ )
+
+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..ac0531b 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,35 @@ 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.VENUS_EXPERIMENTAL -> null
+ DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL -> null
+ }
+ }
+}
diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt
index 1553974..0c2688c 100644
--- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt
@@ -1,6 +1,7 @@
package org.randomcoder.udroid.runtime
import android.content.Context
+import android.system.Os
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -15,6 +16,7 @@ import java.io.File
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
+import java.nio.file.LinkOption
import java.nio.file.StandardCopyOption
import java.util.UUID
@@ -131,6 +133,41 @@ object ProotMountProfileValidator {
}
object ProotMountResolver {
+ fun resolve(
+ context: Context,
+ rootfs: File,
+ profile: ProotMountProfile,
+ sessionMounts: List = emptyList(),
+ ): List {
+ val customTargets =
+ profile.customMounts.filter(ProotCustomMount::enabled).mapTo(mutableSetOf()) {
+ it.guestTarget
+ }
+ val compatibilityMounts =
+ if (profile.isDefaultEnabled("android.proc")) {
+ prepareCompatibilityMounts(runtimeDirectory(context, rootfs).resolve("sysdata"))
+ .filterNot { isShadowedByCustomTarget(it.guestTarget, customTargets) }
+ } else {
+ emptyList()
+ }
+ val selinuxMount =
+ prepareSelinuxMount(runtimeDirectory(context, rootfs).resolve("sysdata"))
+ ?.takeIf {
+ profile.isDefaultEnabled("android.sys") &&
+ !isShadowedByCustomTarget(it.guestTarget, customTargets)
+ }
+ val sharedMemoryMount =
+ prepareSharedMemoryMount(runtimeDirectory(context, rootfs).resolve("shm"))
+ .takeIf { !isShadowedByCustomTarget(it.guestTarget, customTargets) }
+ return resolve(
+ profile = profile,
+ sessionMounts =
+ sessionMounts +
+ compatibilityMounts +
+ listOfNotNull(selinuxMount, sharedMemoryMount),
+ )
+ }
+
fun resolve(
profile: ProotMountProfile,
sessionMounts: List = emptyList(),
@@ -163,6 +200,130 @@ object ProotMountResolver {
return resolved
}
+ internal fun sharedMemoryMount(hostSource: String): ResolvedProotMount =
+ ResolvedProotMount(
+ hostSource = hostSource,
+ guestTarget = "/dev/shm",
+ origin = "runtime:shm",
+ )
+
+ private fun runtimeDirectory(
+ context: Context,
+ rootfs: File,
+ ): File {
+ require(SAFE_SYSTEM_ID.matches(rootfs.name)) { "Unsafe Linux system ID: ${rootfs.name}" }
+ return File(context.noBackupFilesDir, "proot/${rootfs.name}")
+ }
+
+ fun removeRuntime(
+ context: Context,
+ systemId: String,
+ ) {
+ require(SAFE_SYSTEM_ID.matches(systemId)) { "Unsafe Linux system ID: $systemId" }
+ val parent = File(context.noBackupFilesDir, "proot").toPath()
+ val target = parent.resolve(systemId)
+ if (Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
+ RootfsTreeDeleter.delete(parent, systemId)
+ } else {
+ Files.deleteIfExists(target)
+ }
+ }
+
+ internal fun isShadowedByCustomTarget(
+ guestTarget: String,
+ customTargets: Set,
+ ): Boolean {
+ val target = File(guestTarget).toPath().normalize().toString()
+ return customTargets.any { custom ->
+ val normalized = File(custom).toPath().normalize().toString()
+ normalized == "/" || target == normalized || target.startsWith("$normalized/")
+ }
+ }
+
+ private fun prepareSharedMemoryMount(directory: File): ResolvedProotMount {
+ directory.apply {
+ check(mkdirs() || isDirectory) {
+ "Could not prepare shared memory storage"
+ }
+ Os.chmod(absolutePath, 0x3ff) // 01777, matching a normal Linux /dev/shm.
+ }
+ return sharedMemoryMount(directory.absolutePath)
+ }
+
+ @Synchronized
+ internal fun prepareCompatibilityMounts(
+ directory: File,
+ readable: (String) -> Boolean = ::isReadable,
+ ): List {
+ if (!prepareDirectory(directory)) return emptyList()
+ return COMPATIBILITY_FILES.mapNotNull { entry ->
+ if (readable(entry.guestTarget)) return@mapNotNull null
+ val source = File(directory, entry.name)
+ val staging = runCatching {
+ Files.createTempFile(directory.toPath(), ".compat-", ".tmp").toFile()
+ }.getOrNull() ?: return@mapNotNull null
+ try {
+ FileOutputStream(staging).use { output ->
+ output.write(entry.contents.toByteArray(Charsets.UTF_8))
+ output.fd.sync()
+ }
+ moveReplacing(staging, source)
+ } catch (_: Exception) {
+ return@mapNotNull null
+ } finally {
+ staging.delete()
+ }
+ if (!Files.isRegularFile(source.toPath(), LinkOption.NOFOLLOW_LINKS)) {
+ return@mapNotNull null
+ }
+ ResolvedProotMount(
+ hostSource = source.absolutePath,
+ guestTarget = entry.guestTarget,
+ origin = "runtime:compatibility",
+ )
+ }
+ }
+
+ private fun prepareSelinuxMount(directory: File): ResolvedProotMount? {
+ if (!prepareDirectory(directory)) return null
+ val empty = File(directory, "sys_empty")
+ if (Files.isSymbolicLink(empty.toPath()) || (empty.exists() && !empty.isDirectory)) {
+ if (!empty.delete()) return null
+ }
+ if (!empty.mkdirs() && !empty.isDirectory) return null
+ return ResolvedProotMount(
+ hostSource = empty.absolutePath,
+ guestTarget = "/sys/fs/selinux",
+ origin = "runtime:compatibility",
+ )
+ }
+
+ private fun prepareDirectory(directory: File): Boolean =
+ !Files.isSymbolicLink(directory.toPath()) && (directory.mkdirs() || directory.isDirectory)
+
+ private fun moveReplacing(
+ source: File,
+ target: File,
+ ) {
+ try {
+ Files.move(
+ source.toPath(),
+ target.toPath(),
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ } catch (_: AtomicMoveNotSupportedException) {
+ Files.move(
+ source.toPath(),
+ target.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ }
+ }
+
+ private fun isReadable(path: String): Boolean =
+ runCatching { File(path).inputStream().use { it.read() } }.isSuccess
+
fun defaults(
x11SocketDirectory: String? = null,
audioAuthDirectory: String? = null,
@@ -211,6 +372,36 @@ object ProotMountResolver {
)
}
}
+
+ private data class CompatibilityFile(
+ val name: String,
+ val guestTarget: String,
+ val contents: String,
+ )
+
+ private val COMPATIBILITY_FILES =
+ listOf(
+ CompatibilityFile("loadavg", "/proc/loadavg", "0.00 0.00 0.00 1/1 1\n"),
+ CompatibilityFile("stat", "/proc/stat", "cpu 1 0 1 1 0 0 0 0 0 0\n"),
+ CompatibilityFile("uptime", "/proc/uptime", "0.00 0.00\n"),
+ CompatibilityFile(
+ "version",
+ "/proc/version",
+ "Linux version 5.4.0-udroid-faked (udroid@android)\n",
+ ),
+ CompatibilityFile("vmstat", "/proc/vmstat", "nr_free_pages 0\n"),
+ CompatibilityFile("cap_last_cap", "/proc/sys/kernel/cap_last_cap", "40\n"),
+ CompatibilityFile(
+ "inotify_max_user_watches",
+ "/proc/sys/fs/inotify/max_user_watches",
+ "4096\n",
+ ),
+ CompatibilityFile("overflowuid", "/proc/sys/kernel/overflowuid", "65534\n"),
+ CompatibilityFile("overflowgid", "/proc/sys/kernel/overflowgid", "65534\n"),
+ CompatibilityFile("pci_devices", "/proc/bus/pci/devices", ""),
+ )
+
+ private val SAFE_SYSTEM_ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,95}")
}
class ProotMountProfileStore(context: Context) {
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..4c33195 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"
@@ -52,6 +53,8 @@ object ProotTerminalLaunchBuilder {
?: error("The installed Linux image has no supported shell")
val mounts =
ProotMountResolver.resolve(
+ context = context,
+ rootfs = rootfs,
profile = ProotMountProfileStore(context).load(rootfs.name),
sessionMounts =
ProotMountResolver.sessionMounts(
@@ -59,6 +62,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 +76,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 +106,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 +121,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 +129,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 056bc25..c404399 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,11 @@ 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.virgl.VenusGuestRuntimeInstaller
import org.randomcoder.udroid.x11.X11ServerController
import java.io.BufferedReader
import java.io.File
@@ -60,6 +65,9 @@ class RuntimeSupervisorService : Service() {
private var terminalCreationInFlight = false
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()
@@ -67,6 +75,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)
}
@@ -252,6 +261,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()
@@ -309,12 +319,18 @@ class RuntimeSupervisorService : Service() {
.firstOrNull { it.name == rootfsName }
?.directory
?: error("Linux system $rootfsName is not installed or is not ready")
+ val graphicsProfile =
+ desktopConfigurationStore.loadGraphicsProfile(rootfs.name)
+ val virglServerMode =
+ desktopConfigurationStore.loadVirglServerMode(rootfs.name)
ProotTerminalLaunchBuilder.create(
context = this,
runtime = ProotRuntimeInstaller.install(this),
rootfs = rootfs,
x11SocketDirectory = x11SocketDirectory,
audioEndpoint = audioEndpoint,
+ launchProfile =
+ graphicsLaunchProfile(graphicsProfile, virglServerMode, rootfs),
)
}
mainHandler.post {
@@ -544,9 +560,16 @@ class RuntimeSupervisorService : Service() {
return
}
if (application.terminal) {
+ val arguments = listOf(application.executable) + application.arguments
val command =
- (listOf(application.executable) + application.arguments)
+ arguments
.joinToString(" ", transform = ::shellQuote)
+ app.journal.appendCommand(
+ component = "linux-app",
+ command = arguments,
+ bootId = snapshot.bootId,
+ fields = mapOf("desktop_id" to application.id, "transport" to "terminal"),
+ )
writeToTerminal("$command\n")
app.journal.append(
component = "linux-app",
@@ -586,6 +609,13 @@ class RuntimeSupervisorService : Service() {
val result =
runCatching {
val rootfs = InstalledRootfsResolver.resolve(this, rootfsName)
+ val profile = desktopConfigurationStore.loadGraphicsProfile(rootfsName)
+ val launchProfile =
+ graphicsLaunchProfile(
+ profile,
+ desktopConfigurationStore.loadVirglServerMode(rootfsName),
+ rootfs,
+ )
val launch =
ProotApplicationLaunchBuilder.create(
context = this,
@@ -594,7 +624,18 @@ class RuntimeSupervisorService : Service() {
x11SocketDirectory = socketDirectory,
application = application,
audioEndpoint = audioController.endpoint(),
+ launchProfile = launchProfile,
)
+ app.journal.appendCommand(
+ component = "linux-app",
+ command = launch.command,
+ bootId = snapshot.bootId,
+ fields =
+ mapOf(
+ "desktop_id" to application.id,
+ "working_directory" to launch.workingDirectory.absolutePath,
+ ),
+ )
val process =
ProcessBuilder(launch.command)
.directory(launch.workingDirectory)
@@ -604,6 +645,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",
@@ -709,7 +756,7 @@ class RuntimeSupervisorService : Service() {
"rootfs" to request.rootfsName,
"environment_id" to request.environment.id,
"display" to DISPLAY_NUMBER,
- "compositing" to request.configuration.compositingEnabled,
+ "compositing" to request.configuration.effectiveCompositingEnabled,
"touch_scale" to request.configuration.touchScaleEnabled,
"graphics_profile" to request.configuration.graphicsProfile.storageValue,
),
@@ -729,11 +776,29 @@ 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,
+ DesktopGraphicsProfile.VENUS_EXPERIMENTAL,
+ ->
+ virglLaunchProfile(
+ request.configuration.graphicsProfile,
+ request.configuration.virglServerMode,
+ rootfs,
+ )
DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL -> {
lateinit var host: GfxstreamHostController
host =
- GfxstreamHostController(this) { failure ->
+ GfxstreamHostController(
+ this,
+ app.journal,
+ runtime.bootId,
+ ) { failure ->
handleGfxstreamHostExit(host, failure)
}
check(ownedGfxstreamHost.compareAndSet(null, host)) {
@@ -766,6 +831,16 @@ class RuntimeSupervisorService : Service() {
File(cacheDir, "desktop-process-$launchToken.pid").apply {
delete()
}
+ app.journal.appendCommand(
+ component = "desktop",
+ command = launch.command,
+ bootId = runtime.bootId,
+ fields =
+ mapOf(
+ "environment_id" to request.environment.id,
+ "working_directory" to launch.workingDirectory.absolutePath,
+ ),
+ )
val process =
ProcessBuilder(wrapWithPidFile(launch.command, pidFile))
.directory(launch.workingDirectory)
@@ -794,6 +869,7 @@ class RuntimeSupervisorService : Service() {
rootfsName = request.rootfsName,
environment = request.environment,
gfxstreamHost = startingGfxstreamHost,
+ graphicsProfile = request.configuration.graphicsProfile,
mounts = launch.mounts,
)
check(ownedDesktop.compareAndSet(null, owned)) {
@@ -1006,6 +1082,110 @@ class RuntimeSupervisorService : Service() {
ownedGfxstreamHost.getAndSet(null)?.close()
}
+ private fun virglLaunchProfile(
+ profile: DesktopGraphicsProfile,
+ requestedMode: VirglServerMode,
+ rootfs: File,
+ ): VirglProotLaunchProfile =
+ synchronized(virglHostLock) {
+ val backend = checkNotNull(VirglHostBackend.from(profile))
+ val serverMode = backend.resolveServerMode(requestedMode)
+ val guestRuntime =
+ if (backend == VirglHostBackend.VENUS) {
+ VenusGuestRuntimeInstaller.install(this, rootfs)
+ } else {
+ null
+ }
+ val existing = ownedVirglHosts[backend]
+ existing
+ ?.takeIf { it.serverMode == serverMode }
+ ?.currentSocket()
+ ?.let { VirglProotLaunchProfile(it, backend, guestRuntime) }
+ ?: run {
+ if (existing != null) {
+ ownedVirglHosts.remove(backend, existing)
+ existing.close()
+ }
+ lateinit var host: VirglHostController
+ host =
+ VirglHostController(
+ this,
+ backend,
+ serverMode,
+ app.journal,
+ app.runtimeState.current().bootId,
+ ) { failure ->
+ handleVirglHostExit(backend, host, failure)
+ }
+ ownedVirglHosts[backend] = host
+ try {
+ VirglProotLaunchProfile(host.start(), backend, guestRuntime)
+ } catch (error: Throwable) {
+ ownedVirglHosts.remove(backend, host)
+ host.close()
+ throw error
+ }
+ }
+ }
+
+ private fun graphicsLaunchProfile(
+ profile: DesktopGraphicsProfile,
+ virglServerMode: VirglServerMode,
+ rootfs: File,
+ ): ProotLaunchProfile? {
+ val support = checkNotNull(GraphicsProfileCompatibilityProbe.run(this, rootfs)[profile])
+ check(support.available) {
+ support.reason ?: "The selected graphics driver is unavailable"
+ }
+ return when (profile) {
+ DesktopGraphicsProfile.VIRGL,
+ DesktopGraphicsProfile.VIRGL_ANGLE,
+ DesktopGraphicsProfile.VENUS_EXPERIMENTAL,
+ -> virglLaunchProfile(profile, virglServerMode, rootfs)
+ 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,
@@ -1188,6 +1368,8 @@ class RuntimeSupervisorService : Service() {
runCatching {
val runtime = ProotRuntimeInstaller.install(this)
val rootfs = InstalledRootfsResolver.resolve(this, requestedRootfsName)
+ val graphicsProfile = desktopConfigurationStore.loadGraphicsProfile(rootfs.name)
+ val virglServerMode = desktopConfigurationStore.loadVirglServerMode(rootfs.name)
runCatching { audioController.apply(audioConfiguration, bootId) }
.onFailure { error ->
app.journal.append(
@@ -1206,8 +1388,19 @@ class RuntimeSupervisorService : Service() {
rootfs = rootfs,
x11SocketDirectory = x11SocketDirectory,
audioEndpoint = audioController.endpoint(),
+ launchProfile = graphicsLaunchProfile(graphicsProfile, virglServerMode, rootfs),
)
}.mapCatching { launch ->
+ app.journal.appendCommand(
+ component = "terminal",
+ command = launch.arguments.toList(),
+ bootId = bootId,
+ fields =
+ mapOf(
+ "rootfs" to launch.rootfs.name,
+ "working_directory" to launch.workingDirectory,
+ ),
+ )
launch to createTerminalSession(launch)
}.onSuccess { (launch, tab) ->
attachActiveTerminalToViews()
@@ -1400,6 +1593,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)
@@ -1423,6 +1617,7 @@ class RuntimeSupervisorService : Service() {
pendingDesktopRestart.set(null)
stopDesktopProcess(restarting = false)
stopApplicationProcesses()
+ closeVirglHost()
x11Controller.stop(current.bootId)
audioController.stop(current.bootId)
val stopping =
@@ -1590,7 +1785,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",
@@ -1611,6 +1808,7 @@ class RuntimeSupervisorService : Service() {
if (process.isAlive) process.destroy()
}
applicationProcesses.clear()
+ virglApplicationBackends.clear()
}
private fun shellQuote(argument: String): String =
@@ -1792,6 +1990,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 06085bb..79c492d 100644
--- a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt
+++ b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -40,6 +41,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Code
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.DesktopWindows
@@ -50,6 +52,7 @@ import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material.icons.rounded.SystemUpdateAlt
import androidx.compose.material.icons.rounded.Terminal
@@ -67,6 +70,8 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -88,7 +93,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntOffset
-import org.json.JSONObject
import org.randomcoder.udroid.BuildConfig
import org.randomcoder.udroid.catalog.DistroCatalogState
import org.randomcoder.udroid.catalog.DistroVariant
@@ -113,6 +117,7 @@ import org.randomcoder.udroid.runtime.ProotMountProfileStore
import org.randomcoder.udroid.runtime.RuntimePhase
import org.randomcoder.udroid.runtime.RuntimeSnapshot
import org.randomcoder.udroid.runtime.RuntimeSupervisorService
+import org.randomcoder.udroid.runtime.VirglServerMode
import org.randomcoder.udroid.update.AppUpdatePhase
import org.randomcoder.udroid.update.AppUpdateState
import java.time.Instant
@@ -126,6 +131,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 +166,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
}
@@ -214,6 +222,7 @@ fun UdroidApp(
onCompositingChanged: (Boolean) -> Unit,
onTouchScaleChanged: (Boolean) -> Unit,
onGraphicsProfileChanged: (DesktopGraphicsProfile) -> Unit,
+ onVirglServerModeChanged: (VirglServerMode) -> Unit,
onAudioOutputChanged: (Boolean) -> Unit,
onMicrophoneChanged: (Boolean) -> Unit,
onStartDesktop: () -> Unit,
@@ -251,6 +260,7 @@ fun UdroidApp(
-> UdroidDestination.DISTROS
UdroidDestination.MOUNTS,
UdroidDestination.MOUNT_EDITOR,
+ UdroidDestination.ENVIRONMENT,
-> UdroidDestination.DISTROS
else -> activeDestination
}
@@ -303,15 +313,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,
@@ -375,6 +387,7 @@ fun UdroidApp(
onCompositingChanged = onCompositingChanged,
onTouchScaleChanged = onTouchScaleChanged,
onGraphicsProfileChanged = onGraphicsProfileChanged,
+ onVirglServerModeChanged = onVirglServerModeChanged,
onAudioOutputChanged = onAudioOutputChanged,
onMicrophoneChanged = onMicrophoneChanged,
onStartDesktop = onStartDesktop,
@@ -460,6 +473,7 @@ fun UdroidApp(
onCompositingChanged = onCompositingChanged,
onTouchScaleChanged = onTouchScaleChanged,
onGraphicsProfileChanged = onGraphicsProfileChanged,
+ onVirglServerModeChanged = onVirglServerModeChanged,
onAudioOutputChanged = onAudioOutputChanged,
onMicrophoneChanged = onMicrophoneChanged,
onStartDesktop = onStartDesktop,
@@ -479,17 +493,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,
+ )
+ }
}
}
}
@@ -549,6 +565,7 @@ private fun ManagementPane(
onCompositingChanged: (Boolean) -> Unit,
onTouchScaleChanged: (Boolean) -> Unit,
onGraphicsProfileChanged: (DesktopGraphicsProfile) -> Unit,
+ onVirglServerModeChanged: (VirglServerMode) -> Unit,
onAudioOutputChanged: (Boolean) -> Unit,
onMicrophoneChanged: (Boolean) -> Unit,
onStartDesktop: () -> Unit,
@@ -771,6 +788,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(
@@ -870,6 +922,7 @@ private fun ManagementPane(
onCompositingChanged = onCompositingChanged,
onTouchScaleChanged = onTouchScaleChanged,
onGraphicsProfileChanged = onGraphicsProfileChanged,
+ onVirglServerModeChanged = onVirglServerModeChanged,
onAudioOutputChanged = onAudioOutputChanged,
onMicrophoneChanged = onMicrophoneChanged,
onRefreshCapabilities = onRefresh,
@@ -880,6 +933,9 @@ private fun ManagementPane(
onConfigureMounts = {
onSelectMountProfile(mountConfigurationSourceId)
},
+ onConfigureEnvironment = {
+ onDestinationSelected(UdroidDestination.ENVIRONMENT)
+ },
onResetFilesystem = {
onResetRootfs(selectedRootfs.name, selectedDistro)
},
@@ -1371,7 +1427,6 @@ private fun AboutPage(
onInstallUpdate: () -> Unit,
onOpenUpdateRelease: () -> Unit,
) {
- val visibleJournalLines = newestSupervisorEvents(journalLines)
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
LazyColumn(
@@ -1487,11 +1542,7 @@ private fun AboutPage(
Column(modifier = Modifier.weight(1f)) {
UdroidSectionLabel(text = "Diagnostic log")
Text(
- if (journalLines.size > visibleJournalLines.size) {
- "Latest ${visibleJournalLines.size} events"
- } else {
- "Recent app and Linux session events"
- },
+ "Search, inspect, and copy app and Linux session events",
color = UdroidMuted,
style = MaterialTheme.typography.bodySmall,
)
@@ -1521,7 +1572,7 @@ private fun AboutPage(
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(5.dp))
- Text("Copy diagnostic report")
+ Text("Copy report")
}
IconButton(onClick = onRefresh) {
Icon(
@@ -1532,23 +1583,8 @@ private fun AboutPage(
}
}
}
- if (visibleJournalLines.isEmpty()) {
- item {
- Surface(
- color = UdroidRaised,
- shape = MaterialTheme.shapes.medium,
- ) {
- Text(
- "No diagnostic events",
- modifier = Modifier.padding(16.dp),
- color = UdroidMuted,
- )
- }
- }
- } else {
- items(visibleJournalLines) { line ->
- JournalRow(line)
- }
+ item {
+ DiagnosticLogWindow(journalLines)
}
item { Spacer(Modifier.height(16.dp)) }
}
@@ -1667,54 +1703,164 @@ private fun AppUpdateStatusPanel(
}
@Composable
-private fun JournalRow(line: String) {
- val payload = runCatching { JSONObject(line) }.getOrNull()
- val event = payload?.optString("event").orEmpty().ifBlank { "event" }
- val message = payload?.optString("message").orEmpty().ifBlank { line }
- val timestamp = payload?.optString("timestamp").orEmpty()
+private fun DiagnosticLogWindow(journalLines: List) {
+ var query by rememberSaveable { mutableStateOf("") }
+ val entries = remember(journalLines, query) { diagnosticLogEntries(journalLines, query) }
+ val context = LocalContext.current
Surface(
+ modifier = Modifier.fillMaxWidth().height(430.dp),
color = UdroidRaised,
- shape = MaterialTheme.shapes.medium,
+ shape = MaterialTheme.shapes.large,
) {
- Row(
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 11.dp),
- verticalAlignment = Alignment.Top,
- ) {
- Surface(
- modifier = Modifier.size(8.dp).padding(top = 2.dp),
- color = UdroidForest,
- shape = CircleShape,
- ) {}
- Spacer(Modifier.width(11.dp))
- Column(modifier = Modifier.weight(1f)) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
+ Column {
+ OutlinedTextField(
+ value = query,
+ onValueChange = { query = it },
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ singleLine = true,
+ placeholder = { Text("Search diagnostics") },
+ leadingIcon = {
+ Icon(Icons.Rounded.Search, contentDescription = null)
+ },
+ trailingIcon = {
+ if (query.isNotEmpty()) {
+ IconButton(onClick = { query = "" }) {
+ Icon(Icons.Rounded.Clear, contentDescription = "Clear search")
+ }
+ }
+ },
+ colors =
+ OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = UdroidInset,
+ unfocusedContainerColor = UdroidInset,
+ ),
+ )
+ Text(
+ if (query.isBlank()) {
+ "${entries.size} events • newest at bottom"
+ } else {
+ "${entries.size} of ${journalLines.size} events • newest at bottom"
+ },
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp),
+ color = UdroidMuted,
+ fontFamily = FontFamily.Monospace,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(top = 8.dp),
+ color = UdroidLine,
+ )
+ if (entries.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
) {
Text(
- event,
- modifier = Modifier.weight(1f),
+ if (journalLines.isEmpty()) "No diagnostic events" else "No matching events",
+ color = UdroidMuted,
fontFamily = FontFamily.Monospace,
- fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodySmall,
)
- Text(
- timestamp.substringAfter("T").take(8),
- color = UdroidFaint,
- style = MaterialTheme.typography.labelSmall,
- )
}
- Spacer(Modifier.height(3.dp))
- Text(
- message,
- color = UdroidMuted,
- style = MaterialTheme.typography.bodySmall,
- )
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ reverseLayout = true,
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ items(entries) { entry ->
+ DiagnosticLogRow(entry) {
+ context
+ .getSystemService(ClipboardManager::class.java)
+ .setPrimaryClip(
+ ClipData.newPlainText(
+ "uDroid diagnostic log",
+ formatDiagnosticLogEntry(entry),
+ ),
+ )
+ Toast.makeText(
+ context,
+ "Log copied to clipboard",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ }
}
}
}
}
+@Composable
+private fun DiagnosticLogRow(
+ entry: DiagnosticLogEntry,
+ onCopy: () -> Unit,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable(onClickLabel = "Copy log", onClick = onCopy)
+ .padding(vertical = 8.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ entry.timestamp,
+ color = UdroidFaint,
+ fontFamily = FontFamily.Monospace,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ entry.severity.uppercase(),
+ color = diagnosticSeverityColor(entry.severity),
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.SemiBold,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ Spacer(Modifier.height(2.dp))
+ Text(
+ "${entry.component}/${entry.event}",
+ color = UdroidForest,
+ fontFamily = FontFamily.Monospace,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ entry.message,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 5,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ if (entry.fields.isNotEmpty()) {
+ Spacer(Modifier.height(3.dp))
+ Text(
+ entry.fields,
+ color = UdroidMuted,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ HorizontalDivider(
+ modifier = Modifier.padding(top = 10.dp),
+ color = UdroidLine,
+ )
+ }
+}
+
+@Composable
+private fun diagnosticSeverityColor(severity: String): Color =
+ when (severity.lowercase()) {
+ "error", "fatal" -> MaterialTheme.colorScheme.error
+ "warning", "warn" -> UdroidWarning
+ else -> UdroidMuted
+ }
+
private fun installedSystemTitle(rootfsName: String): String =
when {
rootfsName.contains("jammy", ignoreCase = true) -> "Ubuntu 22.04 LTS"
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..31ae0cc 100644
--- a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt
+++ b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt
@@ -1,6 +1,5 @@
package org.randomcoder.udroid.ui
-import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
@@ -21,6 +20,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
@@ -67,12 +67,17 @@ import org.randomcoder.udroid.runtime.DesktopEnvironment
import org.randomcoder.udroid.runtime.DesktopGraphicsProfile
import org.randomcoder.udroid.runtime.DesktopSessionPhase
import org.randomcoder.udroid.runtime.GFXSTREAM_PROFILE_ENABLED
+import org.randomcoder.udroid.runtime.GraphicsProfileCompatibilityProbe
+import org.randomcoder.udroid.runtime.GraphicsProfileSupport
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
import org.randomcoder.udroid.runtime.RuntimeSnapshot
+import org.randomcoder.udroid.runtime.VirglServerMode
import java.text.DateFormat
import java.util.Date
@@ -100,6 +105,7 @@ fun LinuxSystemPage(
onCompositingChanged: (Boolean) -> Unit,
onTouchScaleChanged: (Boolean) -> Unit,
onGraphicsProfileChanged: (DesktopGraphicsProfile) -> Unit,
+ onVirglServerModeChanged: (VirglServerMode) -> Unit,
onAudioOutputChanged: (Boolean) -> Unit,
onMicrophoneChanged: (Boolean) -> Unit,
onRefreshCapabilities: () -> Unit,
@@ -108,13 +114,19 @@ fun LinuxSystemPage(
onStopDesktop: () -> Unit,
onRestartDesktop: () -> Unit,
onConfigureMounts: () -> Unit,
+ onConfigureEnvironment: () -> Unit,
onResetFilesystem: () -> Unit,
onDeleteFilesystem: () -> Unit,
) {
BackHandler(onBack = onBack)
val context = androidx.compose.ui.platform.LocalContext.current
+ val graphicsProfileSupport =
+ remember(rootfs.directory.absolutePath) {
+ GraphicsProfileCompatibilityProbe.run(context, rootfs.directory)
+ }
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 +134,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 +306,31 @@ 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,
+ virglServerMode = configuration.virglServerMode,
+ support = graphicsProfileSupport,
+ runtimeRunning =
+ runtimeOwnsSystem && snapshot.phase == RuntimePhase.RUNNING,
+ desktopRunning = desktopRunning,
+ onSelected = onGraphicsProfileChanged,
+ onVirglServerModeSelected = onVirglServerModeChanged,
+ )
+ }
+ }
+
item(key = "desktop-label") {
UdroidSectionLabel(
text = "Desktop session",
@@ -360,7 +401,6 @@ fun LinuxSystemPage(
desktopRunning = desktopRunning,
onCompositingChanged = onCompositingChanged,
onTouchScaleChanged = onTouchScaleChanged,
- onGraphicsProfileChanged = onGraphicsProfileChanged,
)
}
item(key = "desktop-controls") {
@@ -458,6 +498,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 +702,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,17 +1064,17 @@ private fun DesktopSettingsPanel(
desktopRunning: Boolean,
onCompositingChanged: (Boolean) -> Unit,
onTouchScaleChanged: (Boolean) -> Unit,
- onGraphicsProfileChanged: (DesktopGraphicsProfile) -> Unit,
) {
val compositorSupport = environment.kind.compositorSupport
val compositorConfigurable =
- compositorSupport == DesktopCompositorSupport.CONFIGURABLE
+ compositorSupport == DesktopCompositorSupport.CONFIGURABLE &&
+ configuration.supportsCompositedDesktop
val compositorChecked =
when (compositorSupport) {
DesktopCompositorSupport.REQUIRED -> true
DesktopCompositorSupport.EXTERNAL_OR_NONE -> false
DesktopCompositorSupport.UNKNOWN -> configuration.compositingEnabled
- DesktopCompositorSupport.CONFIGURABLE -> configuration.compositingEnabled
+ DesktopCompositorSupport.CONFIGURABLE -> configuration.effectiveCompositingEnabled
}
Surface(
color = Color.Transparent,
@@ -994,8 +1087,13 @@ private fun DesktopSettingsPanel(
detail =
when (compositorSupport) {
DesktopCompositorSupport.CONFIGURABLE ->
- "Turn off for lower latency, or turn on for effects and transparency." +
- if (desktopRunning) " Restart the desktop to apply." else ""
+ if (configuration.supportsCompositedDesktop) {
+ "Turn off for lower latency, or turn on for effects and transparency." +
+ if (desktopRunning) " Restart the desktop to apply." else ""
+ } else {
+ "Compatibility-mode VirGL requires compositing off. " +
+ "Use multi-client mode only with a matching patched Mesa."
+ }
DesktopCompositorSupport.REQUIRED ->
"${environment.kind.desktopName} requires compositing"
DesktopCompositorSupport.EXTERNAL_OR_NONE ->
@@ -1017,14 +1115,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 +1122,13 @@ private fun DesktopSettingsPanel(
@Composable
private fun GraphicsProfileSelector(
selected: DesktopGraphicsProfile,
+ virglServerMode: VirglServerMode = VirglServerMode.AUTOMATIC,
+ support: Map,
+ runtimeRunning: Boolean,
desktopRunning: Boolean,
onSelected: (DesktopGraphicsProfile) -> Unit,
+ onVirglServerModeSelected: (VirglServerMode) -> Unit,
) {
- val gfxstreamSupported = "arm64-v8a" in Build.SUPPORTED_ABIS
Column(modifier = Modifier.padding(vertical = 8.dp)) {
Text(
"Graphics driver",
@@ -1043,29 +1136,139 @@ private fun GraphicsProfileSelector(
color = UdroidInk,
style = MaterialTheme.typography.titleMedium,
)
- GraphicsProfileRow(
- title = "Standard",
- detail = "Use the distribution’s default graphics driver",
- selected = selected == DesktopGraphicsProfile.STANDARD,
- enabled = true,
- onClick = { onSelected(DesktopGraphicsProfile.STANDARD) },
+ 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,
+ )
+ GraphicsProfileOption(
+ profile = DesktopGraphicsProfile.STANDARD,
+ title = "Automatic",
+ detail = "Use the distribution’s default graphics driver.",
+ selected = selected,
+ support = support,
+ onSelected = onSelected,
+ )
+ GraphicsProfileOption(
+ profile = DesktopGraphicsProfile.SOFTWARE,
+ title = "Software",
+ detail = "Use Mesa llvmpipe on the CPU.",
+ selected = selected,
+ support = support,
+ onSelected = onSelected,
+ )
+ GraphicsProfileOption(
+ profile = DesktopGraphicsProfile.ZINK,
+ title = "Zink",
+ detail = "Use OpenGL over a working Vulkan driver installed inside Linux.",
+ selected = selected,
+ support = support,
+ onSelected = onSelected,
)
- GraphicsProfileRow(
- title = "gfxstream (experimental)",
+ GraphicsProfileOption(
+ profile = DesktopGraphicsProfile.VIRGL,
+ title = "VirGL",
+ detail = "Use Android OpenGL ES with a current Mesa virpipe driver inside Linux.",
+ selected = selected,
+ support = support,
+ onSelected = onSelected,
+ )
+ GraphicsProfileOption(
+ profile = DesktopGraphicsProfile.VIRGL_ANGLE,
+ title = "VirGL + ANGLE",
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) },
+ "Use VirGL through ANGLE and Android Vulkan with a current Mesa " +
+ "virpipe driver inside Linux.",
+ selected = selected,
+ support = support,
+ onSelected = onSelected,
+ )
+ GraphicsProfileOption(
+ profile = DesktopGraphicsProfile.VENUS_EXPERIMENTAL,
+ title = "Venus (experimental)",
+ detail =
+ "Accelerate Vulkan apps through Android’s driver. OpenGL stays on software " +
+ "rendering; X11 presentation uses Mesa’s copy path.",
+ selected = selected,
+ support = support,
+ onSelected = onSelected,
)
+ if ((selected == DesktopGraphicsProfile.VIRGL ||
+ selected == DesktopGraphicsProfile.VIRGL_ANGLE) &&
+ support[selected]?.available == true
+ ) {
+ HorizontalDivider(color = UdroidLine)
+ Text(
+ "VirGL server mode",
+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
+ color = UdroidInk,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ GraphicsProfileRow(
+ title = "Automatic",
+ detail = "Use the compatible mode for distribution Mesa packages.",
+ selected = virglServerMode == VirglServerMode.AUTOMATIC,
+ enabled = true,
+ onClick = { onVirglServerModeSelected(VirglServerMode.AUTOMATIC) },
+ )
+ GraphicsProfileRow(
+ title = "Compatibility",
+ detail = "Stable protocol for Ubuntu, Debian, Arch, and similar guests.",
+ selected = virglServerMode == VirglServerMode.COMPATIBILITY,
+ enabled = true,
+ onClick = { onVirglServerModeSelected(VirglServerMode.COMPATIBILITY) },
+ )
+ GraphicsProfileRow(
+ title = "Multi-client",
+ detail = "Protocol 3 for patched Mesa; supports concurrent GL clients.",
+ selected = virglServerMode == VirglServerMode.MULTI_CLIENT,
+ enabled = true,
+ onClick = { onVirglServerModeSelected(VirglServerMode.MULTI_CLIENT) },
+ )
+ }
+ if (GFXSTREAM_PROFILE_ENABLED) {
+ GraphicsProfileOption(
+ profile = DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL,
+ title = "gfxstream (experimental)",
+ detail = "Use Android’s Vulkan driver.",
+ selected = selected,
+ support = support,
+ onSelected = onSelected,
+ )
+ }
}
}
+@Composable
+private fun GraphicsProfileOption(
+ profile: DesktopGraphicsProfile,
+ title: String,
+ detail: String,
+ selected: DesktopGraphicsProfile,
+ support: Map,
+ onSelected: (DesktopGraphicsProfile) -> Unit,
+) {
+ val profileSupport = support[profile] ?: GraphicsProfileSupport(false, "Compatibility unknown")
+ if (!profileSupport.available && selected != profile) return
+ GraphicsProfileRow(
+ title = title,
+ detail = profileSupport.reason ?: detail,
+ selected = selected == profile,
+ enabled = profileSupport.available,
+ onClick = { onSelected(profile) },
+ )
+}
+
@Composable
private fun GraphicsProfileRow(
title: String,
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..2ff460a
--- /dev/null
+++ b/app/src/main/java/org/randomcoder/udroid/ui/ProotEnvironmentProfilesPage.kt
@@ -0,0 +1,601 @@
+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.VENUS_EXPERIMENTAL -> {
+ set("VN_DEBUG", "vtest")
+ set("MESA_VK_WSI_DEBUG", "sw")
+ }
+ DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL -> Unit
+ }
+ return values
+}
diff --git a/app/src/main/java/org/randomcoder/udroid/ui/SupervisorReport.kt b/app/src/main/java/org/randomcoder/udroid/ui/SupervisorReport.kt
index 1c435bc..23ade6a 100644
--- a/app/src/main/java/org/randomcoder/udroid/ui/SupervisorReport.kt
+++ b/app/src/main/java/org/randomcoder/udroid/ui/SupervisorReport.kt
@@ -1,9 +1,75 @@
package org.randomcoder.udroid.ui
-internal fun newestSupervisorEvents(
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+internal data class DiagnosticLogEntry(
+ val raw: String,
+ val timestamp: String,
+ val severity: String,
+ val component: String,
+ val event: String,
+ val message: String,
+ val fields: String,
+)
+
+internal fun formatDiagnosticLogEntry(entry: DiagnosticLogEntry): String =
+ buildString {
+ append(entry.timestamp)
+ append(' ')
+ append(entry.severity.uppercase())
+ append(' ')
+ append(entry.component)
+ append('/')
+ appendLine(entry.event)
+ append(entry.message)
+ if (entry.fields.isNotEmpty()) {
+ appendLine()
+ append(entry.fields)
+ }
+ }
+
+internal fun diagnosticLogEntries(
newestFirstJournalLines: List,
- limit: Int = 25,
-): List = newestFirstJournalLines.take(limit)
+ query: String,
+): List {
+ val entries = newestFirstJournalLines.map(::parseDiagnosticLogEntry)
+ if (query.isBlank()) return entries
+ return entries.filter {
+ it.raw.contains(query, ignoreCase = true) ||
+ it.displayText.contains(query, ignoreCase = true)
+ }
+}
+
+private val DiagnosticLogEntry.displayText: String
+ get() = "$timestamp $severity $component $event $message $fields"
+
+private fun parseDiagnosticLogEntry(line: String): DiagnosticLogEntry {
+ val payload = runCatching { Json.parseToJsonElement(line).jsonObject }.getOrNull()
+ ?: return DiagnosticLogEntry(line, "", "raw", "journal", "event", line, "")
+ val fields = payload["fields"] as? JsonObject
+ return DiagnosticLogEntry(
+ raw = line,
+ timestamp = payload.string("timestamp").replace('T', ' '),
+ severity = payload.string("severity").ifBlank { "info" },
+ component = payload.string("component").ifBlank { "app" },
+ event = payload.string("event").ifBlank { "event" },
+ message = payload.string("message").ifBlank { line },
+ fields =
+ fields
+ ?.toSortedMap()
+ ?.entries
+ ?.joinToString(" ") { (key, value) ->
+ "$key=${(value as? JsonPrimitive)?.content ?: value}"
+ }
+ .orEmpty(),
+ )
+}
+
+private fun JsonObject.string(key: String): String = this[key]?.jsonPrimitive?.content.orEmpty()
internal fun buildSupervisorReport(
appVersion: String,
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..ef71ff5
--- /dev/null
+++ b/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostController.kt
@@ -0,0 +1,243 @@
+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
+import org.randomcoder.udroid.runtime.EventJournal
+import org.randomcoder.udroid.runtime.VirglServerMode
+
+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"),
+ VENUS("Android Vulkan (Venus)", "venus.sock", "venus.log"),
+ ;
+
+ fun resolveServerMode(requested: VirglServerMode): VirglServerMode =
+ if (this == VENUS) {
+ VirglServerMode.MULTI_CLIENT
+ } else {
+ requested.takeUnless { it == VirglServerMode.AUTOMATIC }
+ ?: VirglServerMode.COMPATIBILITY
+ }
+
+ companion object {
+ fun from(profile: DesktopGraphicsProfile): VirglHostBackend? =
+ when (profile) {
+ DesktopGraphicsProfile.VIRGL -> NATIVE_GLES
+ DesktopGraphicsProfile.VIRGL_ANGLE -> ANGLE_VULKAN
+ DesktopGraphicsProfile.VENUS_EXPERIMENTAL -> VENUS
+ 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,
+ serverMode: VirglServerMode,
+ ): List =
+ buildList {
+ val resolvedMode = backend.resolveServerMode(serverMode)
+ add("--no-fork")
+ if (resolvedMode.multiClients) add("--multi-clients")
+ if (backend == VirglHostBackend.ANGLE_VULKAN) add("--angle-vulkan")
+ if (backend == VirglHostBackend.VENUS) add("--venus")
+ add("--socket-path")
+ add(socket.absolutePath)
+ }
+
+ fun environment(
+ home: File,
+ libraryDirectory: File,
+ renderServerExecutable: 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",
+ "RENDER_SERVER_EXEC_PATH" to renderServerExecutable.absolutePath,
+ "TMPDIR" to temporaryDirectory.absolutePath,
+ "VIRGL_LOG_LEVEL" to "info",
+ ) +
+ 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,
+ val serverMode: VirglServerMode,
+ private val journal: EventJournal,
+ private val bootId: String?,
+ 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 command =
+ AndroidExecutableCommand.create(
+ runtime.executable,
+ *VirglHostLaunch.arguments(socket, backend, serverMode).toTypedArray(),
+ )
+ journal.appendCommand(
+ component = "virgl",
+ command = command,
+ bootId = bootId,
+ fields = mapOf("working_directory" to graphicsDirectory.absolutePath),
+ )
+ val launched =
+ ProcessBuilder(command)
+ .directory(graphicsDirectory)
+ .redirectErrorStream(true)
+ .redirectOutput(logFile)
+ .apply {
+ environment().clear()
+ environment().putAll(
+ VirglHostLaunch.environment(
+ appContext.filesDir,
+ runtime.libraryDirectory,
+ runtime.renderServerExecutable,
+ 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} · " +
+ "${serverMode.storageValue} · 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" && process.get()?.isAlive == true && 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..c0d93ed
--- /dev/null
+++ b/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostRuntime.kt
@@ -0,0 +1,131 @@
+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 renderServerExecutable: File,
+ val libraryDirectory: File,
+)
+
+internal data class VirglAngleRuntime(
+ val libraryDirectory: File,
+)
+
+internal data class VenusGuestRuntime(
+ val directory: File,
+)
+
+internal object VirglHostRuntimeInstaller {
+ const val VERSION = "1.3.0-6"
+ private val bundle =
+ VerifiedRuntimeAssetBundle(
+ name = "VirGL host",
+ assetDirectory = "virgl-host",
+ destinationPrefix = "virgl-host",
+ version = VERSION,
+ entries =
+ listOf(
+ "bin/virgl_test_server_android",
+ "libexec/virgl_render_server",
+ "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",
+ "libexec/virgl_render_server",
+ ),
+ 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"),
+ renderServerExecutable = File(directory, "libexec/virgl_render_server"),
+ 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"),
+ )
+}
+
+internal object VenusGuestRuntimeInstaller {
+ const val GUEST_DIRECTORY = "/opt/udroid/venus-mesa"
+ const val VERSION = "26.1.5-jammy"
+ private val bundle =
+ VerifiedRuntimeAssetBundle(
+ name = "Venus Mesa runtime for Ubuntu 22.04 and 24.04",
+ assetDirectory = "venus-mesa",
+ destinationPrefix = "venus-mesa",
+ version = VERSION,
+ entries =
+ listOf(
+ "lib/dri/zink_dri.so",
+ "lib/libEGL_mesa.so.0",
+ "lib/libGLX_mesa.so.0",
+ "lib/libgallium-26.1.5.so",
+ "lib/libvulkan_virtio.so",
+ "share/doc/license.rst",
+ "share/drirc.d/00-mesa-defaults.conf",
+ "share/glvnd/egl_vendor.d/50_mesa.json",
+ "share/vulkan/icd.d/virtio_icd.aarch64.json",
+ ),
+ metadataKeys = setOf("mesa_commit"),
+ )
+
+ fun install(
+ context: Context,
+ rootfs: File,
+ ): VenusGuestRuntime? {
+ if (!requiresBundledMesa(rootfs)) return null
+ val directory = VerifiedRuntimeAssetInstaller.install(context, bundle)
+ return VenusGuestRuntime(directory)
+ }
+
+ internal fun requiresBundledMesa(rootfs: File): Boolean {
+ val values =
+ runCatching {
+ File(rootfs, "etc/os-release").useLines { lines ->
+ lines
+ .mapNotNull { line ->
+ line.split('=', limit = 2).takeIf { it.size == 2 }
+ }.associate { (key, value) ->
+ key to value.removeSurrounding("\"").removeSurrounding("'")
+ }
+ }
+ }.getOrNull() ?: return false
+ return values["ID"] == "ubuntu" && values["VERSION_ID"] in setOf("22.04", "24.04")
+ }
+}
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..ecda65c
--- /dev/null
+++ b/app/src/main/java/org/randomcoder/udroid/virgl/VirglProotLaunchProfile.kt
@@ -0,0 +1,67 @@
+package org.randomcoder.udroid.virgl
+
+import java.io.File
+import org.randomcoder.udroid.runtime.ProotLaunchProfile
+
+internal data class VirglProotLaunchProfile(
+ val socket: File,
+ val backend: VirglHostBackend = VirglHostBackend.NATIVE_GLES,
+ val venusGuestRuntime: VenusGuestRuntime? = null,
+) : ProotLaunchProfile {
+ init {
+ require(socket.exists()) { "The VirGL socket is unavailable" }
+ require(venusGuestRuntime == null || backend == VirglHostBackend.VENUS) {
+ "The Venus guest runtime requires the Venus backend"
+ }
+ require(venusGuestRuntime == null || venusGuestRuntime.directory.isDirectory) {
+ "The Venus guest runtime is unavailable"
+ }
+ }
+
+ override fun addBindings(arguments: MutableList) {
+ arguments += "-b"
+ arguments += "${socket.absolutePath}:$GUEST_SOCKET"
+ venusGuestRuntime?.let {
+ arguments += "-b"
+ arguments += "${it.directory.absolutePath}:${VenusGuestRuntimeInstaller.GUEST_DIRECTORY}"
+ }
+ }
+
+ override fun wrapGuestCommand(command: List): List {
+ require(command.isNotEmpty()) { "A guest command is required" }
+ return listOf(
+ "/usr/bin/env",
+ "-u",
+ "LIBGL_ALWAYS_SOFTWARE",
+ ) +
+ if (backend == VirglHostBackend.VENUS) {
+ listOf(
+ "-u",
+ "GALLIUM_DRIVER",
+ "-u",
+ "MESA_LOADER_DRIVER_OVERRIDE",
+ "VN_DEBUG=vtest",
+ "MESA_VK_WSI_DEBUG=sw",
+ ) +
+ if (venusGuestRuntime != null) {
+ listOf(
+ "LD_LIBRARY_PATH=${VenusGuestRuntimeInstaller.GUEST_DIRECTORY}/lib:" +
+ "/usr/lib/aarch64-linux-gnu:/lib/aarch64-linux-gnu",
+ "LIBGL_DRIVERS_PATH=${VenusGuestRuntimeInstaller.GUEST_DIRECTORY}/lib/dri",
+ "VK_ICD_FILENAMES=${VenusGuestRuntimeInstaller.GUEST_DIRECTORY}/share/" +
+ "vulkan/icd.d/virtio_icd.aarch64.json",
+ "__EGL_VENDOR_LIBRARY_FILENAMES=${VenusGuestRuntimeInstaller.GUEST_DIRECTORY}/" +
+ "share/glvnd/egl_vendor.d/50_mesa.json",
+ )
+ } else {
+ emptyList()
+ } + command
+ } else {
+ listOf("GALLIUM_DRIVER=virpipe") + command
+ }
+ }
+
+ companion object {
+ const val GUEST_SOCKET = "/tmp/.virgl_test"
+ }
+}
diff --git a/app/src/test/java/org/randomcoder/udroid/install/ProotRootfsHealthCheckTest.kt b/app/src/test/java/org/randomcoder/udroid/install/ProotRootfsHealthCheckTest.kt
index 69bf08b..3879c38 100644
--- a/app/src/test/java/org/randomcoder/udroid/install/ProotRootfsHealthCheckTest.kt
+++ b/app/src/test/java/org/randomcoder/udroid/install/ProotRootfsHealthCheckTest.kt
@@ -4,6 +4,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.randomcoder.udroid.runtime.ANDROID_PROOT_BIND_MOUNTS
+import org.randomcoder.udroid.runtime.ResolvedProotMount
class ProotRootfsHealthCheckTest {
@Test
@@ -12,6 +13,15 @@ class ProotRootfsHealthCheckTest {
ProotRootfsHealthCheck.buildArguments(
rootfsPath = "/data/user/0/udroid/files/rootfs/jammy",
shellPath = "/bin/sh",
+ mounts =
+ ANDROID_PROOT_BIND_MOUNTS.map {
+ ResolvedProotMount(it, it, "default:test")
+ } +
+ ResolvedProotMount(
+ "/data/user/0/udroid/no_backup/proot/jammy/sysdata/pci_devices",
+ "/proc/bus/pci/devices",
+ "runtime:compatibility",
+ ),
)
val bindings =
@@ -21,7 +31,11 @@ class ProotRootfsHealthCheckTest {
.filter { it[0] == "-b" }
.map { it[1] }
- assertEquals(ANDROID_PROOT_BIND_MOUNTS, bindings)
+ assertEquals(
+ ANDROID_PROOT_BIND_MOUNTS +
+ "/data/user/0/udroid/no_backup/proot/jammy/sysdata/pci_devices:/proc/bus/pci/devices",
+ bindings,
+ )
assertTrue("/dev" in bindings)
assertTrue(arguments.toList().windowed(2).contains(listOf("/usr/bin/env", "-i")))
}
diff --git a/app/src/test/java/org/randomcoder/udroid/install/RootfsPipelineTest.kt b/app/src/test/java/org/randomcoder/udroid/install/RootfsPipelineTest.kt
index ba15534..13167e6 100644
--- a/app/src/test/java/org/randomcoder/udroid/install/RootfsPipelineTest.kt
+++ b/app/src/test/java/org/randomcoder/udroid/install/RootfsPipelineTest.kt
@@ -145,13 +145,11 @@ class RootfsPipelineTest {
}
@Test
- fun `android compatibility configuration creates missing proc files`() {
+ fun `android compatibility configuration creates guest configuration`() {
val rootfs = Files.createTempDirectory("udroid-configurator-test").toFile()
AndroidRootfsConfigurator().configure(rootfs)
- assertTrue(File(rootfs, "proc/.version").isFile)
- assertTrue(File(rootfs, "proc/.uptime").isFile)
assertTrue(File(rootfs, "etc/hosts").readText().contains("localhost"))
assertTrue(File(rootfs, "etc/profile.d/udroid.sh").canExecute())
}
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..62a3a9a 100644
--- a/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt
+++ b/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt
@@ -111,8 +111,7 @@ class DesktopEnvironmentScannerTest {
assertTrue(arguments.contains("GDK_SCALE=2"))
val script = arguments[arguments.indexOf("-lc") + 1]
assertTrue(script.contains("-s false"))
- assertTrue(script.contains("sleep 1"))
- assertTrue(script.contains("wait \"\$desktop_pid\""))
+ assertTrue(script.endsWith("exec \"\$@\""))
assertEquals(desktop.command, arguments.takeLast(desktop.command.size))
assertFalse(script.contains(desktop.command.last()))
}
@@ -171,6 +170,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 +251,60 @@ 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),
+ )
+ assertEquals(
+ DesktopGraphicsProfile.VENUS_EXPERIMENTAL,
+ DesktopGraphicsProfile.fromStorage(
+ DesktopGraphicsProfile.VENUS_EXPERIMENTAL.storageValue,
+ ),
+ )
+ }
+
+ @Test
+ fun `VirGL server modes survive storage and unknown values stay automatic`() {
+ VirglServerMode.entries.forEach { mode ->
+ assertEquals(mode, VirglServerMode.fromStorage(mode.storageValue))
+ }
+ assertEquals(VirglServerMode.AUTOMATIC, VirglServerMode.fromStorage("future-mode"))
+ }
+
+ @Test
+ fun `VirGL compatibility mode disables desktop compositing`() {
+ val compatible =
+ DesktopConfiguration(
+ environmentId = "xfce",
+ compositingEnabled = true,
+ touchScaleEnabled = false,
+ graphicsProfile = DesktopGraphicsProfile.VIRGL_ANGLE,
+ )
+ assertFalse(compatible.effectiveCompositingEnabled)
+ assertTrue(
+ compatible.copy(virglServerMode = VirglServerMode.MULTI_CLIENT)
+ .effectiveCompositingEnabled,
+ )
+ assertTrue(
+ compatible.copy(graphicsProfile = DesktopGraphicsProfile.VENUS_EXPERIMENTAL)
+ .effectiveCompositingEnabled,
+ )
+ }
+
@Test
fun `inactive gfxstream profile safely falls back to standard`() {
assertEquals(
diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/EventJournalTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/EventJournalTest.kt
new file mode 100644
index 0000000..b02d88e
--- /dev/null
+++ b/app/src/test/java/org/randomcoder/udroid/runtime/EventJournalTest.kt
@@ -0,0 +1,24 @@
+package org.randomcoder.udroid.runtime
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class EventJournalTest {
+ @Test
+ fun commandFormattingPreservesEveryArgumentAndIsShellSafe() {
+ assertEquals(
+ "'proot' '/bin/sh' '-lc' 'echo '\"'\"'hello world'\"'\"'' 'API_TOKEN=value echo ready' '--password' 'value'",
+ formatCommandForLog(
+ listOf(
+ "proot",
+ "/bin/sh",
+ "-lc",
+ "echo 'hello world'",
+ "API_TOKEN=value echo ready",
+ "--password",
+ "value",
+ ),
+ ),
+ )
+ }
+}
diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/GraphicsProfileCompatibilityTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/GraphicsProfileCompatibilityTest.kt
new file mode 100644
index 0000000..a0a25f8
--- /dev/null
+++ b/app/src/test/java/org/randomcoder/udroid/runtime/GraphicsProfileCompatibilityTest.kt
@@ -0,0 +1,58 @@
+package org.randomcoder.udroid.runtime
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GraphicsProfileCompatibilityTest {
+ @Test
+ fun `only exposes profiles whose device and guest requirements are present`() {
+ val compatibleDevice =
+ GraphicsDeviceSupport(
+ arm64 = true,
+ openGlEsVersion = 0x00030000,
+ vulkanVersion = (1 shl 22) or (1 shl 12),
+ hardwareBuffer = true,
+ )
+ val compatibleGuest =
+ GraphicsGuestSupport(
+ software = true,
+ zink = true,
+ vulkanIcd = true,
+ virgl = true,
+ venus = true,
+ )
+
+ val supported = GraphicsProfileCompatibilityProbe.classify(compatibleDevice, compatibleGuest)
+ assertTrue(supported.getValue(DesktopGraphicsProfile.VIRGL).available)
+ assertTrue(supported.getValue(DesktopGraphicsProfile.VIRGL_ANGLE).available)
+ assertTrue(supported.getValue(DesktopGraphicsProfile.VENUS_EXPERIMENTAL).available)
+
+ val noAndroidVulkan =
+ GraphicsProfileCompatibilityProbe.classify(
+ compatibleDevice.copy(vulkanVersion = 0, hardwareBuffer = false),
+ compatibleGuest,
+ )
+ assertTrue(noAndroidVulkan.getValue(DesktopGraphicsProfile.VIRGL).available)
+ assertTrue(noAndroidVulkan.getValue(DesktopGraphicsProfile.ZINK).available)
+ assertFalse(noAndroidVulkan.getValue(DesktopGraphicsProfile.VIRGL_ANGLE).available)
+ assertFalse(noAndroidVulkan.getValue(DesktopGraphicsProfile.VENUS_EXPERIMENTAL).available)
+
+ val minimalGuest =
+ GraphicsProfileCompatibilityProbe.classify(
+ compatibleDevice,
+ compatibleGuest.copy(
+ software = false,
+ zink = false,
+ vulkanIcd = false,
+ virgl = false,
+ venus = false,
+ ),
+ )
+ assertTrue(minimalGuest.getValue(DesktopGraphicsProfile.STANDARD).available)
+ assertFalse(minimalGuest.getValue(DesktopGraphicsProfile.SOFTWARE).available)
+ assertFalse(minimalGuest.getValue(DesktopGraphicsProfile.ZINK).available)
+ assertFalse(minimalGuest.getValue(DesktopGraphicsProfile.VIRGL).available)
+ assertFalse(minimalGuest.getValue(DesktopGraphicsProfile.VENUS_EXPERIMENTAL).available)
+ }
+}
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/ProotMountProfilesTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt
index adf58ad..42a721d 100644
--- a/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt
+++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt
@@ -5,6 +5,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
+import java.nio.file.Files
class ProotMountProfilesTest {
@Test
@@ -98,6 +99,58 @@ class ProotMountProfilesTest {
assertEquals("runtime:x11", mounts.last().origin)
}
+ @Test
+ fun `runtime shared memory mount targets dev shm`() {
+ val mount = ProotMountResolver.sharedMemoryMount("/data/user/0/udroid/no_backup/proot/shm")
+
+ assertEquals("/data/user/0/udroid/no_backup/proot/shm:/dev/shm", mount.argument)
+ assertEquals("runtime:shm", mount.origin)
+ }
+
+ @Test
+ fun `compatibility files only replace unreadable Android data`() {
+ val directory = Files.createTempDirectory("udroid-sysdata").toFile()
+ val victim = Files.createTempFile("udroid-sysdata-victim", null).toFile().apply {
+ writeText("preserve")
+ }
+ Files.createSymbolicLink(directory.resolve("pci_devices").toPath(), victim.toPath())
+
+ val mounts =
+ ProotMountResolver.prepareCompatibilityMounts(directory) { path ->
+ path == "/proc/version"
+ }
+
+ assertFalse(mounts.any { it.guestTarget == "/proc/version" })
+ assertEquals("", mounts.single { it.guestTarget == "/proc/bus/pci/devices" }.let {
+ java.io.File(it.hostSource).readText()
+ })
+ assertEquals("preserve", victim.readText())
+ assertFalse(Files.isSymbolicLink(directory.resolve("pci_devices").toPath()))
+ assertEquals(
+ "4096\n",
+ mounts.single { it.guestTarget.endsWith("max_user_watches") }.let {
+ java.io.File(it.hostSource).readText()
+ },
+ )
+ assertTrue(mounts.all { it.origin == "runtime:compatibility" })
+ }
+
+ @Test
+ fun `parent custom mount shadows automatic child mounts`() {
+ assertTrue(
+ ProotMountResolver.isShadowedByCustomTarget(
+ "/proc/bus/pci/devices",
+ setOf("/proc//"),
+ ),
+ )
+ assertFalse(
+ ProotMountResolver.isShadowedByCustomTarget(
+ "/proc/bus/pci/devices",
+ setOf("/proc/other"),
+ ),
+ )
+ }
+
@Test
fun `profile codec round trips overrides and custom mappings`() {
val expected =
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/SupervisorReportTest.kt b/app/src/test/java/org/randomcoder/udroid/ui/SupervisorReportTest.kt
index 9305b7c..3e8b3d9 100644
--- a/app/src/test/java/org/randomcoder/udroid/ui/SupervisorReportTest.kt
+++ b/app/src/test/java/org/randomcoder/udroid/ui/SupervisorReportTest.kt
@@ -5,13 +5,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisorReportTest {
- @Test
- fun previewReturnsTheNewestEvents() {
- val journal = listOf("newest", "middle", "oldest")
-
- assertEquals(listOf("newest", "middle"), newestSupervisorEvents(journal, limit = 2))
- }
-
@Test
fun reportIncludesContextAndOrdersEventsChronologically() {
val report =
@@ -28,4 +21,27 @@ class SupervisorReportTest {
assertTrue(report.contains("Device: Google Pixel"))
assertTrue(report.indexOf("""{"event":"old"}""") < report.indexOf("""{"event":"new"}"""))
}
+
+ @Test
+ fun diagnosticEntriesFormatAndSearchStructuredFields() {
+ val line =
+ """{"timestamp":"2026-09-27T09:00:00Z","component":"x11","severity":"warning","event":"socket_wait","message":"Waiting for display","fields":{"display":":1"}}"""
+
+ val entry = diagnosticLogEntries(listOf(line), "display").single()
+
+ assertEquals("2026-09-27 09:00:00Z", entry.timestamp)
+ assertEquals("warning", entry.severity)
+ assertEquals("x11", entry.component)
+ assertEquals("socket_wait", entry.event)
+ assertEquals("Waiting for display", entry.message)
+ assertEquals("display=:1", entry.fields)
+ assertEquals(1, diagnosticLogEntries(listOf(line), "WAITING").size)
+ assertTrue(diagnosticLogEntries(listOf(line), "missing").isEmpty())
+ assertEquals(
+ "2026-09-27 09:00:00Z WARNING x11/socket_wait\n" +
+ "Waiting for display\n" +
+ "display=:1",
+ formatDiagnosticLogEntry(entry),
+ )
+ }
}
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..8970cc8
--- /dev/null
+++ b/app/src/test/java/org/randomcoder/udroid/virgl/VirglHostLaunchTest.kt
@@ -0,0 +1,230 @@
+package org.randomcoder.udroid.virgl
+
+import java.io.File
+import java.nio.file.Files
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.randomcoder.udroid.runtime.VirglServerMode
+
+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,
+ VirglServerMode.MULTI_CLIENT,
+ ),
+ )
+ }
+
+ @Test
+ fun `starts the ANGLE Vulkan backend on a separate socket`() {
+ assertEquals(
+ listOf(
+ "--no-fork",
+ "--angle-vulkan",
+ "--socket-path",
+ "/private/graphics/virgl-angle-vulkan.sock",
+ ),
+ VirglHostLaunch.arguments(
+ File("/private/graphics/virgl-angle-vulkan.sock"),
+ VirglHostBackend.ANGLE_VULKAN,
+ VirglServerMode.COMPATIBILITY,
+ ),
+ )
+ }
+
+ @Test
+ fun `starts the Venus backend on its own socket`() {
+ assertEquals(
+ listOf(
+ "--no-fork",
+ "--multi-clients",
+ "--venus",
+ "--socket-path",
+ "/private/graphics/venus.sock",
+ ),
+ VirglHostLaunch.arguments(
+ File("/private/graphics/venus.sock"),
+ VirglHostBackend.VENUS,
+ VirglServerMode.COMPATIBILITY,
+ ),
+ )
+ }
+
+ @Test
+ fun `Venus always supports concurrent Vulkan clients`() {
+ assertEquals(
+ VirglServerMode.MULTI_CLIENT,
+ VirglHostBackend.VENUS.resolveServerMode(VirglServerMode.AUTOMATIC),
+ )
+ assertEquals(
+ VirglServerMode.MULTI_CLIENT,
+ VirglHostBackend.VENUS.resolveServerMode(VirglServerMode.COMPATIBILITY),
+ )
+ }
+
+ @Test
+ fun `loads the packaged host libraries`() {
+ val environment =
+ VirglHostLaunch.environment(
+ home = File("/private/files"),
+ libraryDirectory = File("/private/lib"),
+ renderServerExecutable = File("/private/libexec/virgl_render_server"),
+ temporaryDirectory = File("/private/cache"),
+ )
+
+ assertEquals("/private/lib", environment["LD_LIBRARY_PATH"])
+ assertEquals("/private/cache", environment["TMPDIR"])
+ assertEquals("/system/bin", environment["PATH"])
+ assertEquals("info", environment["VIRGL_LOG_LEVEL"])
+ assertEquals(
+ "/private/libexec/virgl_render_server",
+ environment["RENDER_SERVER_EXEC_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"),
+ renderServerExecutable = File("/private/libexec/virgl_render_server"),
+ 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(
+ VirglHostBackend.VENUS,
+ VirglHostBackend.from(
+ org.randomcoder.udroid.runtime.DesktopGraphicsProfile.VENUS_EXPERIMENTAL,
+ ),
+ )
+ 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")),
+ )
+ }
+
+ @Test
+ fun `selects Venus vtest with the X11 copy presentation path`() {
+ val socket = Files.createTempFile("udroid-venus", ".sock").toFile()
+ val runtime = Files.createTempDirectory("udroid-venus-runtime").toFile()
+ val profile =
+ VirglProotLaunchProfile(
+ socket,
+ VirglHostBackend.VENUS,
+ VenusGuestRuntime(runtime),
+ )
+ val bindings = mutableListOf()
+
+ profile.addBindings(bindings)
+
+ assertEquals(
+ listOf(
+ "-b",
+ "${socket.absolutePath}:/tmp/.virgl_test",
+ "-b",
+ "${runtime.absolutePath}:/opt/udroid/venus-mesa",
+ ),
+ bindings,
+ )
+ assertEquals(
+ listOf(
+ "/usr/bin/env",
+ "-u",
+ "LIBGL_ALWAYS_SOFTWARE",
+ "-u",
+ "GALLIUM_DRIVER",
+ "-u",
+ "MESA_LOADER_DRIVER_OVERRIDE",
+ "VN_DEBUG=vtest",
+ "MESA_VK_WSI_DEBUG=sw",
+ "LD_LIBRARY_PATH=/opt/udroid/venus-mesa/lib:" +
+ "/usr/lib/aarch64-linux-gnu:/lib/aarch64-linux-gnu",
+ "LIBGL_DRIVERS_PATH=/opt/udroid/venus-mesa/lib/dri",
+ "VK_ICD_FILENAMES=/opt/udroid/venus-mesa/share/vulkan/icd.d/" +
+ "virtio_icd.aarch64.json",
+ "__EGL_VENDOR_LIBRARY_FILENAMES=/opt/udroid/venus-mesa/share/glvnd/" +
+ "egl_vendor.d/50_mesa.json",
+ "/usr/bin/vkcube",
+ ),
+ profile.wrapGuestCommand(listOf("/usr/bin/vkcube")),
+ )
+ }
+
+ @Test
+ fun `uses bundled Venus Mesa for supported Ubuntu releases`() {
+ val rootfs = Files.createTempDirectory("udroid-venus-rootfs").toFile()
+ val osRelease = rootfs.resolve("etc/os-release")
+ requireNotNull(osRelease.parentFile).mkdirs()
+ osRelease.writeText("ID=ubuntu\nVERSION_ID=\"22.04\"\n")
+
+ assertTrue(VenusGuestRuntimeInstaller.requiresBundledMesa(rootfs))
+
+ osRelease.writeText("ID=ubuntu\nVERSION_ID=\"24.04\"\n")
+ assertTrue(VenusGuestRuntimeInstaller.requiresBundledMesa(rootfs))
+
+ osRelease.writeText("ID=ubuntu\nVERSION_ID=\"20.04\"\n")
+ assertFalse(VenusGuestRuntimeInstaller.requiresBundledMesa(rootfs))
+ }
+}