diff --git a/app/src/dev/AndroidManifest.xml b/app/src/dev/AndroidManifest.xml index 5fdb55a..65b5aee 100644 --- a/app/src/dev/AndroidManifest.xml +++ b/app/src/dev/AndroidManifest.xml @@ -1,6 +1,10 @@ + + android:exported="true" + android:windowSoftInputMode="adjustResize"> diff --git a/app/src/main/assets/runtime/arm64-v8a/proot b/app/src/main/assets/runtime/arm64-v8a/proot index 258855c..5ac5334 100755 Binary files a/app/src/main/assets/runtime/arm64-v8a/proot and b/app/src/main/assets/runtime/arm64-v8a/proot differ 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 index 0c07667..48b3831 100644 --- a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/MANIFEST.properties +++ b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/MANIFEST.properties @@ -3,11 +3,12 @@ runtime=26.1.5-jammy abi=arm64-v8a source_package=mesa mesa_commit=6a02618ccf6c5651ecb9cccbde571eb61fd73592 +bin/zink_check_requirements.sha256=690177f11f92a12ab413b4548051362960c4df892ad3124a89c7ad2e6b16d438 lib/dri/zink_dri.so.sha256=a85c32422ab9c0b1b98495fc2274d8bfc7c6892f02496d60f884023ca45ccc41 -lib/libEGL_mesa.so.0.sha256=b6b2e58a5e2b0b1aa6687891272625c89d727166b326cdf67cc2f040ae1cc484 +lib/libEGL_mesa.so.0.sha256=013607840e63dc6cbca87e3ca3126f46734ec102b2072b5757d509615a3a1a46 lib/libGLX_mesa.so.0.sha256=36bad44d10cdb8610cbdfce7c532c4520acd4f1a9f9e92943e66606cc530b2fb -lib/libgallium-26.1.5.so.sha256=1a190093d84f64372c9ae1b001b4adc0d226b308486dc21d8565c53f2a1df75b -lib/libvulkan_virtio.so.sha256=82b57a2c7b7ea71e2cef92919a9529c5ff2dcda9bc7f53ed1bdf38bc81ca6e47 +lib/libgallium-26.1.5.so.sha256=00de405a69fab61c49f0dbbe6a13c0b8332ffb664f20ed021baba0b713d2f06c +lib/libvulkan_virtio.so.sha256=9a21b21f90484960b3a3383144e7bd65bb725ede546ae4ce205f978b894f54a1 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 diff --git a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/bin/zink_check_requirements b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/bin/zink_check_requirements new file mode 100755 index 0000000..9d2165f Binary files /dev/null and b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/bin/zink_check_requirements 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 index 16a2816..5c6c76d 100644 Binary files a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libEGL_mesa.so.0 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/libgallium-26.1.5.so b/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libgallium-26.1.5.so index 680fbed..8cfb220 100644 Binary files a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libgallium-26.1.5.so 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 index 01002ab..367fc2e 100644 Binary files a/app/src/main/assets/runtime/arm64-v8a/venus-mesa/lib/libvulkan_virtio.so 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/virgl-host/MANIFEST.properties b/app/src/main/assets/runtime/arm64-v8a/virgl-host/MANIFEST.properties index cbb266a..68a6931 100644 --- a/app/src/main/assets/runtime/arm64-v8a/virgl-host/MANIFEST.properties +++ b/app/src/main/assets/runtime/arm64-v8a/virgl-host/MANIFEST.properties @@ -1,13 +1,13 @@ format=1 -runtime=1.3.0-6 +runtime=1.3.0-7 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 +termux_packages_commit=d9696948261f136ccb9826a047c432f526dc99ba +source_deb_sha256=11595cd177b64c783b0ed6d3669e26b6bbd62f39e0426f1dbaf85d1a035f1c12 +bin/virgl_test_server_android.sha256=62855b51d45eb8200c19ad3710bd6e426d4e19ff5dd28061e9c094b741f5b468 +libexec/virgl_render_server.sha256=659544b68466d8af6ed85bdfd2f126903b9f36ebc6968dad034de0075b7de09c +lib/libepoxy.so.sha256=a50c278d336031d07a5e0ab43b7868496f71e56c7bf908daa0ada7bc32f0d5d8 +lib/libvirglrenderer.so.sha256=af425d45e96916d36417f45157f78518189345ff4a5611d97f5ef3ae28a89c86 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 index c8f4aac..aee5074 100755 Binary files a/app/src/main/assets/runtime/arm64-v8a/virgl-host/bin/virgl_test_server_android 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 index 856fceb..e3eb358 100755 Binary files a/app/src/main/assets/runtime/arm64-v8a/virgl-host/lib/libepoxy.so 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 index 9e36f84..446f77a 100755 Binary files a/app/src/main/assets/runtime/arm64-v8a/virgl-host/lib/libvirglrenderer.so 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 index 1609ca0..086a8d8 100755 Binary files a/app/src/main/assets/runtime/arm64-v8a/virgl-host/libexec/virgl_render_server and b/app/src/main/assets/runtime/arm64-v8a/virgl-host/libexec/virgl_render_server differ diff --git a/app/src/main/assets/runtime/armeabi-v7a/proot b/app/src/main/assets/runtime/armeabi-v7a/proot index 086f93d..6f0b7d8 100755 Binary files a/app/src/main/assets/runtime/armeabi-v7a/proot and b/app/src/main/assets/runtime/armeabi-v7a/proot differ diff --git a/app/src/main/assets/runtime/x86_64/proot b/app/src/main/assets/runtime/x86_64/proot index 64f591b..a664594 100755 Binary files a/app/src/main/assets/runtime/x86_64/proot and b/app/src/main/assets/runtime/x86_64/proot differ diff --git a/app/src/main/java/org/randomcoder/udroid/DebugCommandStore.kt b/app/src/main/java/org/randomcoder/udroid/DebugCommandStore.kt new file mode 100644 index 0000000..e11b24e --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/DebugCommandStore.kt @@ -0,0 +1,57 @@ +package org.randomcoder.udroid + +import android.content.Context + +internal data class DebugCommand( + val action: String, + val rootfsName: String? = null, + val environmentId: String? = null, +) + +internal object DebugCommandStore { + private const val PREFERENCES_NAME = "adb-debug-command" + private const val MAX_AGE_MS = 30_000L + private val actions = + setOf( + "open-system", + "open-desktop", + "start-terminal", + "stop-terminal", + "start-desktop", + "stop-desktop", + ) + + fun isSupported(action: String): Boolean = action in actions + + @Synchronized + fun enqueue(context: Context, command: DebugCommand): Boolean { + require(isSupported(command.action)) + val preferences = preferences(context) + val age = System.currentTimeMillis() - preferences.getLong("created-at", 0L) + if (preferences.contains("action") && age in 0..MAX_AGE_MS) return false + return preferences.edit() + .putString("action", command.action) + .putString("rootfs", command.rootfsName) + .putString("environment", command.environmentId) + .putLong("created-at", System.currentTimeMillis()) + .commit() + } + + @Synchronized + fun take(context: Context): DebugCommand? { + val preferences = preferences(context) + val action = preferences.getString("action", null) ?: return null + val age = System.currentTimeMillis() - preferences.getLong("created-at", 0L) + val command = + DebugCommand( + action = action, + rootfsName = preferences.getString("rootfs", null), + environmentId = preferences.getString("environment", null), + ) + check(preferences.edit().clear().commit()) { "Could not clear the debug command" } + return command.takeIf { age in 0..MAX_AGE_MS && isSupported(action) } + } + + private fun preferences(context: Context) = + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt index 975f609..0b0e349 100644 --- a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt +++ b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt @@ -134,6 +134,8 @@ class MainActivity : ComponentActivity() { private var pendingDesktopStart: PendingDesktopStart? = null private var pendingShortcutRootfsName: String? = null private var pendingTerminalRootfsName: String? = null + private var pendingDebugDesktop: DebugCommand? = null + private var pendingDebugStopDesktop = false private var pendingMicrophoneRootfsName: String? = null private var linuxApplicationsLoadGeneration = 0L private var desktopScanGeneration = 0L @@ -179,6 +181,10 @@ class MainActivity : ComponentActivity() { (binder as? RuntimeSupervisorService.RuntimeBinder)?.service() runtimeService = connectedService refreshFromDisk() + if (pendingDebugStopDesktop) { + pendingDebugStopDesktop = false + connectedService?.stopDesktop() + } launchPendingLinuxApplication() launchPendingDesktop() if (snapshot.desiredRunning && connectedService?.currentTerminalSession() == null) { @@ -319,6 +325,7 @@ class MainActivity : ComponentActivity() { handleUpdateIntent(intent) handleInstallerIntent(intent) if (!handleShortcutIntent(intent)) loadLinuxApplications() + handleDebugCommand() } override fun onNewIntent(intent: Intent) { @@ -327,6 +334,7 @@ class MainActivity : ComponentActivity() { handleUpdateIntent(intent) handleInstallerIntent(intent) handleShortcutIntent(intent) + handleDebugCommand() } override fun onStart() { @@ -979,6 +987,7 @@ class MainActivity : ComponentActivity() { "${environments.size} desktop session" + if (environments.size == 1) " detected" else "s detected" } + launchPendingDebugDesktop(rootfsName) }, onFailure = { desktopEnvironments = emptyList() @@ -989,11 +998,78 @@ class MainActivity : ComponentActivity() { touchScaleEnabled = true, ) desktopScanMessage = it.message ?: "Desktop detection failed" + if (pendingDebugDesktop?.rootfsName == rootfsName) { + debugCommandError(desktopScanMessage ?: "Desktop detection failed") + } + pendingDebugDesktop = null }, ) } } + private fun handleDebugCommand() { + if (!BuildConfig.DEBUG) return + val command = DebugCommandStore.take(this) ?: return + val rootfsName = command.rootfsName ?: installedRootfsName + app.journal.append( + component = "debug", + severity = "info", + event = "adb_command", + message = command.action, + bootId = snapshot.bootId, + fields = mapOf("rootfs" to rootfsName, "environment" to command.environmentId), + ) + if (command.action in setOf("open-system", "start-terminal", "start-desktop") && + installedRootfses.none { it.name == rootfsName } + ) { + debugCommandError("Linux system ${rootfsName ?: "(none)"} is not installed") + return + } + when (command.action) { + "open-system" -> openSystemDetails(rootfsName!!) + "open-desktop" -> selectDestination(UdroidDestination.DESKTOP) + "start-terminal" -> openRootfsTerminal(rootfsName!!) + "stop-terminal" -> RuntimeSupervisorService.stop(this) + "start-desktop" -> { + pendingDebugDesktop = command.copy(rootfsName = rootfsName) + openSystemDetails(rootfsName!!) + } + "stop-desktop" -> { + if (runtimeService == null) pendingDebugStopDesktop = true + else runtimeService?.stopDesktop() + } + } + } + + private fun launchPendingDebugDesktop(rootfsName: String) { + val command = pendingDebugDesktop?.takeIf { it.rootfsName == rootfsName } ?: return + pendingDebugDesktop = null + val requestedEnvironment = command.environmentId + if (requestedEnvironment != null) { + if (desktopEnvironments.none { it.id == requestedEnvironment }) { + debugCommandError("Desktop $requestedEnvironment is not installed in $rootfsName") + return + } + selectDesktopEnvironment(requestedEnvironment) + } + if (desktopEnvironments.isEmpty()) { + debugCommandError("No desktop session is installed in $rootfsName") + return + } + startSelectedDesktop() + selectDestination(UdroidDestination.DESKTOP) + } + + private fun debugCommandError(message: String) { + app.journal.append( + component = "debug", + severity = "error", + event = "adb_command_rejected", + message = message, + bootId = snapshot.bootId, + ) + } + private fun selectDesktopEnvironment(environmentId: String) { val rootfsName = selectedSystemRootfsName ?: return val environment = desktopEnvironments.firstOrNull { it.id == environmentId } ?: return diff --git a/app/src/main/java/org/randomcoder/udroid/catalog/DistroCatalog.kt b/app/src/main/java/org/randomcoder/udroid/catalog/DistroCatalog.kt index 9471258..b712ab5 100644 --- a/app/src/main/java/org/randomcoder/udroid/catalog/DistroCatalog.kt +++ b/app/src/main/java/org/randomcoder/udroid/catalog/DistroCatalog.kt @@ -21,7 +21,7 @@ data class DistroVariant( val architecture: String, val downloadUrl: String, val sha256: String, - val distribution: LinuxDistribution = LinuxDistribution.UBUNTU, + val distribution: LinuxDistribution = LinuxDistribution.UNKNOWN, val provider: DistroProvider = DistroProvider.UDROID, val releaseLabel: String? = null, val archiveStripComponents: Int = 0, @@ -45,6 +45,8 @@ data class DistroVariant( "resolute" -> "Ubuntu 26.04 LTS" else -> "Ubuntu ${suite.replaceFirstChar { it.titlecase() }}" } + LinuxDistribution.DEBIAN -> "Debian ${suite.replaceFirstChar { it.titlecase() }}" + LinuxDistribution.UNKNOWN -> "Unknown Linux" else -> distribution.displayName } @@ -81,6 +83,7 @@ enum class LinuxDistribution( val id: String, val displayName: String, ) { + UNKNOWN("unknown", "Unknown Linux"), UBUNTU("ubuntu", "Ubuntu"), DEBIAN("debian", "Debian"), ARCH("arch", "Arch Linux"), @@ -128,6 +131,10 @@ object DistroCatalogParser { for (suiteElement in suites) { val suiteName = suiteElement.jsonPrimitive.content val suite = root[suiteName]?.jsonObject ?: continue + val distribution = + suite["distribution"]?.jsonPrimitive?.contentOrNull + ?.let { id -> LinuxDistribution.entries.firstOrNull { it.id == id } } + ?: LinuxDistribution.UNKNOWN val variantNames = suite["varients"]?.jsonArray ?: continue for (variantElement in variantNames) { @@ -149,6 +156,7 @@ object DistroCatalogParser { architecture = architecture, downloadUrl = downloadUrl, sha256 = entry[shaKey]?.jsonPrimitive?.contentOrNull.orEmpty(), + distribution = distribution, ) } } @@ -266,6 +274,7 @@ class DistroCatalogRepository(private val context: Context) { downloadUrl = "https://github.com/RandomCoderOrg/udroid-download/releases/download/V3R113/jammy-raw-arm64.tar.gz", sha256 = "63f8dbb323570f1bd4c149c774dd05717f611111aa5da3105a32255139f69d26", + distribution = LinuxDistribution.UBUNTU, ) "armhf" -> DistroVariant( @@ -277,6 +286,7 @@ class DistroCatalogRepository(private val context: Context) { downloadUrl = "https://github.com/RandomCoderOrg/udroid-download/releases/download/V3R113/jammy-raw-armhf.tar.gz", sha256 = "7ef2a462747a06522db8d99e7a8c73cff4afd90275dad35b9971fea3b5ff783f", + distribution = LinuxDistribution.UBUNTU, ) "amd64" -> DistroVariant( @@ -288,6 +298,7 @@ class DistroCatalogRepository(private val context: Context) { downloadUrl = "https://github.com/RandomCoderOrg/udroid-download/releases/download/V3R100/jammy-raw-amd64.tar.gz", sha256 = "eadbdf2bd7d4e9caecb6e57df303d32807fdc2f21fd58565682e8399ab27942c", + distribution = LinuxDistribution.UBUNTU, ) else -> error("No built-in fallback for $architecture") } 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 da46741..326b5a2 100644 --- a/app/src/main/java/org/randomcoder/udroid/install/AndroidRootfsRuntime.kt +++ b/app/src/main/java/org/randomcoder/udroid/install/AndroidRootfsRuntime.kt @@ -33,7 +33,7 @@ data class ProotRuntime( ) object ProotRuntimeInstaller { - private const val PROOT_VERSION = "5.1.107.86-1" + private const val PROOT_VERSION = "5.1.107.101-1" private const val TAR_VERSION = "1.35-1" private val supportedAbis = setOf("arm64-v8a", "armeabi-v7a", "x86_64") diff --git a/app/src/main/java/org/randomcoder/udroid/install/InstallStateStore.kt b/app/src/main/java/org/randomcoder/udroid/install/InstallStateStore.kt index 583552b..0e66a7e 100644 --- a/app/src/main/java/org/randomcoder/udroid/install/InstallStateStore.kt +++ b/app/src/main/java/org/randomcoder/udroid/install/InstallStateStore.kt @@ -117,7 +117,7 @@ internal object InstallProgressCodec { ?.jsonPrimitive ?.contentOrNull ?.let { id -> LinuxDistribution.entries.firstOrNull { it.id == id } } - ?: LinuxDistribution.UBUNTU, + ?: LinuxDistribution.UNKNOWN, provider = value["provider"] ?.jsonPrimitive diff --git a/app/src/main/java/org/randomcoder/udroid/install/InstallerWorkRequest.kt b/app/src/main/java/org/randomcoder/udroid/install/InstallerWorkRequest.kt index f901e76..fc44972 100644 --- a/app/src/main/java/org/randomcoder/udroid/install/InstallerWorkRequest.kt +++ b/app/src/main/java/org/randomcoder/udroid/install/InstallerWorkRequest.kt @@ -129,7 +129,7 @@ object InstallerWorkRequestCodec { ?.let { id -> LinuxDistribution.entries.firstOrNull { it.id == id } } - ?: LinuxDistribution.UBUNTU, + ?: LinuxDistribution.UNKNOWN, provider = value["provider"] ?.jsonPrimitive 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 c404399..9ae0210 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt @@ -45,6 +45,7 @@ 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.virgl.parseZinkRequirements import org.randomcoder.udroid.x11.X11ServerController import java.io.BufferedReader import java.io.File @@ -816,6 +817,28 @@ class RuntimeSupervisorService : Service() { ) } } + if (request.configuration.graphicsProfile == + DesktopGraphicsProfile.VENUS_EXPERIMENTAL + ) { + runCatching { + logZinkRequirements( + rootfs = rootfs, + socketDirectory = socketDirectory, + launchProfile = launchProfile, + bootId = runtime.bootId, + ) + }.onFailure { error -> + app.journal.append( + component = "graphics", + severity = "warning", + event = "zink_requirements_failed", + message = + error.message ?: "Could not run the Zink requirements probe", + bootId = runtime.bootId, + fields = mapOf("rootfs" to rootfs.name), + ) + } + } val launch = ProotDesktopLaunchBuilder.create( context = this, @@ -1147,6 +1170,83 @@ class RuntimeSupervisorService : Service() { } } + private fun logZinkRequirements( + rootfs: File, + socketDirectory: File, + launchProfile: ProotLaunchProfile?, + bootId: String?, + ) { + val profile = launchProfile as? VirglProotLaunchProfile ?: return + if (profile.backend != VirglHostBackend.VENUS || profile.venusGuestRuntime == null) return + val checker = "${VenusGuestRuntimeInstaller.GUEST_DIRECTORY}/bin/zink_check_requirements" + val launch = + ProotApplicationLaunchBuilder.create( + context = this, + runtime = ProotRuntimeInstaller.install(this), + rootfs = rootfs, + x11SocketDirectory = socketDirectory, + application = + LinuxApplication( + id = "udroid-zink-requirements", + name = "Zink requirements", + genericName = null, + comment = null, + executable = "/usr/bin/timeout", + arguments = listOf("5s", checker), + iconName = null, + iconPath = null, + desktopFilePath = "", + desktopFileGuestPath = "", + workingDirectory = "/root", + categories = emptyList(), + terminal = true, + ), + launchProfile = profile, + ) + app.journal.appendCommand( + component = "graphics", + command = launch.command, + bootId = bootId, + fields = mapOf("probe" to "zink_check_requirements", "rootfs" to rootfs.name), + ) + val process = + ProcessBuilder(launch.command) + .directory(launch.workingDirectory) + .redirectErrorStream(true) + .apply { + environment().clear() + environment().putAll(launch.environment) + }.start() + val output = process.inputStream.bufferedReader().use { it.readText() } + val exitCode = process.waitFor() + val results = parseZinkRequirements(output) + val highest = results.lastOrNull { it.supported } + val summary = + when { + exitCode != 0 -> "Zink requirements probe failed with exit code $exitCode" + highest == null -> "Venus does not satisfy the minimum Zink profile" + else -> "Highest supported Zink profile: ${highest.label}" + } + app.journal.append( + component = "graphics", + severity = if (exitCode != 0 || highest == null) "warning" else "info", + event = "zink_requirements", + message = summary, + bootId = bootId, + fields = mapOf("report" to output.trim(), "rootfs" to rootfs.name), + ) + results.forEach { result -> + app.journal.append( + component = "graphics", + severity = if (result.supported) "debug" else "info", + event = "zink_profile", + message = "${result.label}: ${if (result.supported) "supported" else "unsupported"}", + bootId = bootId, + fields = mapOf("profile" to result.profile), + ) + } + } + private fun closeVirglHost() { synchronized(virglHostLock) { ownedVirglHosts.values.forEach(VirglHostController::close) 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 519b4f4..f1c69ff 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt @@ -959,6 +959,7 @@ private fun ManagementPane( UdroidDestination.DEVICE -> DevicePage( capabilities = capabilities, + journalLines = journalLines, onRefresh = onRefresh, ) UdroidDestination.ABOUT -> @@ -1316,9 +1317,11 @@ private fun updateStatusText(state: AppUpdateState): String = @Composable private fun DevicePage( capabilities: List, + journalLines: List, onRefresh: () -> Unit, ) { val openDeveloperOptions = rememberDeveloperOptionsAction(onRefresh) + val zinkCapabilities = remember(journalLines) { zinkCompatibilityResults(journalLines) } LazyColumn( modifier = Modifier @@ -1348,6 +1351,34 @@ private fun DevicePage( onOpenDeveloperOptions = openDeveloperOptions, ) } + item { + UdroidSectionLabel( + text = "Venus and Zink", + modifier = Modifier.padding(top = 8.dp), + ) + } + if (zinkCapabilities.isEmpty()) { + item { + CapabilityRow( + capability = + CapabilityResult( + name = "Zink through Venus", + status = CapabilityStatus.INFO, + detail = + "Not checked yet. Start a desktop with Venus, then refresh this page.", + required = false, + ), + onOpenDeveloperOptions = openDeveloperOptions, + ) + } + } else { + items(zinkCapabilities) { capability -> + CapabilityRow( + capability = capability, + onOpenDeveloperOptions = openDeveloperOptions, + ) + } + } item { Spacer(Modifier.height(16.dp)) } } } @@ -1884,11 +1915,13 @@ private fun installedSystemTitle(rootfsName: String): String = private fun distributionFromSystemId(systemId: String): LinuxDistribution { val normalized = systemId.lowercase() return when { - "debian" in normalized -> LinuxDistribution.DEBIAN + "debian" in normalized || "trixie" in normalized -> LinuxDistribution.DEBIAN "arch" in normalized -> LinuxDistribution.ARCH "alpine" in normalized -> LinuxDistribution.ALPINE "void" in normalized -> LinuxDistribution.VOID - else -> LinuxDistribution.UBUNTU + listOf("ubuntu", "focal", "jammy", "noble", "oracular", "plucky", "questing", "resolute") + .any(normalized::contains) -> LinuxDistribution.UBUNTU + else -> LinuxDistribution.UNKNOWN } } diff --git a/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt index 439f1a8..2ec6be4 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt @@ -9,6 +9,8 @@ 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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -26,16 +28,20 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import org.randomcoder.udroid.runtime.RuntimePhase @@ -58,6 +64,8 @@ fun DesktopPage( mutableStateOf(!settings.startControlsCollapsed) } var inputPaletteVisible by rememberSaveable { mutableStateOf(false) } + var displayKeysVisible by rememberSaveable { mutableStateOf(false) } + var arrangingDisplayKeys by rememberSaveable { mutableStateOf(false) } var showSettings by remember { mutableStateOf(false) } var displayView by remember { mutableStateOf(null) } BackHandler { @@ -65,16 +73,28 @@ fun DesktopPage( showSettings = false } else if (inputPaletteVisible) { inputPaletteVisible = false + } else if (arrangingDisplayKeys) { + arrangingDisplayKeys = false + } else if (displayKeysVisible) { + displayKeysVisible = false } else { onExit() } } var status by remember { mutableStateOf("Waiting for the supervised X11 renderer") } val updateSettings: (X11Settings) -> Unit = { updated -> + if (updated.displayKeys.size > settings.displayKeys.size) displayKeysVisible = true settings = settingsStore.save(updated) displayView?.applySettings(updated) } + LaunchedEffect(arrangingDisplayKeys, showSettings) { + if (arrangingDisplayKeys && !showSettings) { + withFrameNanos { } + displayView?.hideKeyboard() + } + } + DisposableEffect(service, displayView, snapshot.bootId) { val view = displayView if ( @@ -114,14 +134,26 @@ fun DesktopPage( modifier = Modifier.fillMaxSize(), color = Color.Black, ) { - Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier.fillMaxSize().navigationBarsPadding().then( + if (settings.avoidSoftKeyboard) Modifier.imePadding() else Modifier, + ), + ) { if (controlsExpanded) { DesktopControlBar( status = status, inputPaletteVisible = inputPaletteVisible, + displayKeysVisible = displayKeysVisible, + arrangingDisplayKeys = arrangingDisplayKeys, onExit = onExit, onKeyboard = { displayView?.showKeyboard() }, onInputPalette = { inputPaletteVisible = !inputPaletteVisible }, + onKeyboardPalette = { + if (arrangingDisplayKeys) arrangingDisplayKeys = false + else if (settings.displayKeys.isEmpty()) showSettings = true + else displayKeysVisible = !displayKeysVisible + }, onSettings = { showSettings = true }, onCollapse = { controlsExpanded = false }, ) @@ -146,7 +178,15 @@ fun DesktopPage( }, update = { it.displayView.applySettings(settings) + it.onDisplayKeysMoved = { moved -> + updateSettings(settings.copy(displayKeys = moved)) + } it.setPaletteVisible(inputPaletteVisible) + it.setKeyboardBindings(settings.overlayKeys) + it.setKeyboardStripVisible(settings.showKeyboardStrip) + it.setDisplayKeys(settings.displayKeys) + it.setDisplayKeysVisible(displayKeysVisible) + it.setDisplayKeysArranging(arrangingDisplayKeys) it.setPaletteColors( primaryContainer = palettePrimary, surfaceContainer = paletteSurface, @@ -164,6 +204,12 @@ fun DesktopPage( X11SettingsDialog( settings = settings, onSettingsChanged = updateSettings, + onArrangeDisplayKeys = { + displayKeysVisible = true + arrangingDisplayKeys = true + controlsExpanded = true + showSettings = false + }, onDismiss = { showSettings = false }, ) } @@ -173,9 +219,12 @@ fun DesktopPage( private fun DesktopControlBar( status: String, inputPaletteVisible: Boolean, + displayKeysVisible: Boolean, + arrangingDisplayKeys: Boolean, onExit: () -> Unit, onKeyboard: () -> Unit, onInputPalette: () -> Unit, + onKeyboardPalette: () -> Unit, onSettings: () -> Unit, onCollapse: () -> Unit, ) { @@ -206,7 +255,7 @@ private fun DesktopControlBar( style = MaterialTheme.typography.labelSmall, ) Text( - text = status.removePrefix("Display :0 "), + text = if (arrangingDisplayKeys) "Drag buttons to move" else status.removePrefix("Display :0 "), color = UdroidTerminalText, style = MaterialTheme.typography.bodySmall, maxLines = 1, @@ -236,6 +285,20 @@ private fun DesktopControlBar( }, ) } + IconButton( + onClick = onKeyboardPalette, + modifier = Modifier.semantics { + contentDescription = + if (arrangingDisplayKeys) "Done arranging display keys" + else if (displayKeysVisible) "Hide display keys" else "Show display keys" + }, + ) { + Text( + text = if (arrangingDisplayKeys) "Done" else "Keys", + color = if (displayKeysVisible) UdroidTerminalGreen else UdroidTerminalText, + style = MaterialTheme.typography.labelSmall, + ) + } IconButton(onClick = onSettings) { Icon( imageVector = Icons.Rounded.Settings, 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 818fc03..ba0da2e 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt @@ -1207,7 +1207,8 @@ private fun GraphicsProfileSelector( title = "Venus (experimental)", detail = "Accelerate Vulkan apps through Android’s driver. OpenGL stays on software " + - "rendering; X11 presentation uses Mesa’s copy path.", + "rendering; X11 presentation uses Mesa’s copy path. Zink profile results " + + "appear in Device compatibility after starting a desktop.", selected = selected, support = support, onSelected = onSelected, @@ -1390,11 +1391,13 @@ private fun compositorSummary(environment: DesktopEnvironment): String = private fun distributionFrom(rootfsName: String): LinuxDistribution { val normalized = rootfsName.lowercase() return when { - "debian" in normalized -> LinuxDistribution.DEBIAN + "debian" in normalized || "trixie" in normalized -> LinuxDistribution.DEBIAN "arch" in normalized -> LinuxDistribution.ARCH "alpine" in normalized -> LinuxDistribution.ALPINE "void" in normalized -> LinuxDistribution.VOID - else -> LinuxDistribution.UBUNTU + listOf("ubuntu", "focal", "jammy", "noble", "oracular", "plucky", "questing", "resolute") + .any(normalized::contains) -> LinuxDistribution.UBUNTU + else -> LinuxDistribution.UNKNOWN } } diff --git a/app/src/main/java/org/randomcoder/udroid/ui/ProotEnvironmentProfilesPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/ProotEnvironmentProfilesPage.kt index 2ff460a..b18dcfe 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/ProotEnvironmentProfilesPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/ProotEnvironmentProfilesPage.kt @@ -593,7 +593,6 @@ private fun automaticManagedEnvironmentValues( -> set("GALLIUM_DRIVER", "virpipe") DesktopGraphicsProfile.VENUS_EXPERIMENTAL -> { set("VN_DEBUG", "vtest") - set("MESA_VK_WSI_DEBUG", "sw") } DesktopGraphicsProfile.GFXSTREAM_EXPERIMENTAL -> Unit } 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 23ade6a..929870d 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/SupervisorReport.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/SupervisorReport.kt @@ -5,6 +5,8 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import org.randomcoder.udroid.runtime.CapabilityResult +import org.randomcoder.udroid.runtime.CapabilityStatus internal data class DiagnosticLogEntry( val raw: String, @@ -14,6 +16,7 @@ internal data class DiagnosticLogEntry( val event: String, val message: String, val fields: String, + val fieldValues: Map = emptyMap(), ) internal fun formatDiagnosticLogEntry(entry: DiagnosticLogEntry): String = @@ -51,6 +54,10 @@ 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 + val fieldValues = + fields + ?.mapValues { (_, value) -> (value as? JsonPrimitive)?.content ?: value.toString() } + .orEmpty() return DiagnosticLogEntry( raw = line, timestamp = payload.string("timestamp").replace('T', ' '), @@ -59,16 +66,66 @@ private fun parseDiagnosticLogEntry(line: String): DiagnosticLogEntry { event = payload.string("event").ifBlank { "event" }, message = payload.string("message").ifBlank { line }, fields = - fields - ?.toSortedMap() + fieldValues + .toSortedMap() ?.entries ?.joinToString(" ") { (key, value) -> - "$key=${(value as? JsonPrimitive)?.content ?: value}" + "$key=$value" } .orEmpty(), + fieldValues = fieldValues, ) } +internal fun zinkCompatibilityResults( + newestFirstJournalLines: List, +): List { + val entries = newestFirstJournalLines.map(::parseDiagnosticLogEntry) + val summaryIndex = + entries.indexOfFirst { + it.component == "graphics" && + it.event in setOf("zink_requirements", "zink_requirements_failed") + } + if (summaryIndex < 0) return emptyList() + + val summary = entries[summaryIndex] + val rootfs = summary.fieldValues["rootfs"] + return buildList { + add( + CapabilityResult( + name = "Zink through Venus", + status = + if (summary.severity.equals("warning", ignoreCase = true)) { + CapabilityStatus.WARNING + } else { + CapabilityStatus.PASS + }, + detail = listOfNotNull(summary.message, rootfs?.let { "Linux system: $it" }).joinToString(" · "), + required = false, + ), + ) + entries + .take(summaryIndex) + .filter { it.component == "graphics" && it.event == "zink_profile" } + .asReversed() + .forEach { entry -> + val supported = entry.message.endsWith(": supported") + add( + CapabilityResult( + name = entry.message.substringBeforeLast(": "), + status = if (supported) CapabilityStatus.PASS else CapabilityStatus.FAIL, + detail = + listOfNotNull( + entry.fieldValues["profile"], + rootfs?.let { "Linux system: $it" }, + ).joinToString(" · "), + required = false, + ), + ) + } + } +} + private fun JsonObject.string(key: String): String = this[key]?.jsonPrimitive?.content.orEmpty() internal fun buildSupervisorReport( diff --git a/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt index 4c09a83..de7f9a6 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt @@ -650,9 +650,9 @@ private fun StoppingTerminalState( private fun terminalDistribution(rootfsName: String): LinuxDistribution? { val normalized = rootfsName.lowercase() return when { - listOf("focal", "jammy", "noble", "resolute", "ubuntu") + listOf("focal", "jammy", "noble", "oracular", "plucky", "questing", "resolute", "ubuntu") .any(normalized::contains) -> LinuxDistribution.UBUNTU - "debian" in normalized -> LinuxDistribution.DEBIAN + "debian" in normalized || "trixie" in normalized -> LinuxDistribution.DEBIAN "arch" in normalized -> LinuxDistribution.ARCH "alpine" in normalized -> LinuxDistribution.ALPINE "void" in normalized -> LinuxDistribution.VOID diff --git a/app/src/main/java/org/randomcoder/udroid/ui/UiComponents.kt b/app/src/main/java/org/randomcoder/udroid/ui/UiComponents.kt index 5251ed2..a8a86ff 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/UiComponents.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/UiComponents.kt @@ -160,6 +160,7 @@ fun DistroMark( painter = painterResource( when (distribution) { + LinuxDistribution.UNKNOWN -> R.drawable.distro_unknown LinuxDistribution.UBUNTU -> R.drawable.distro_ubuntu LinuxDistribution.DEBIAN -> R.drawable.distro_debian LinuxDistribution.ARCH -> R.drawable.distro_arch diff --git a/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt b/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt index 313c7fd..abf901b 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt @@ -14,13 +14,42 @@ import android.widget.LinearLayout import android.widget.TextView import com.termux.x11.input.InputStub import org.randomcoder.udroid.x11.X11DisplayView +import org.randomcoder.udroid.x11.DisplayKey +import org.randomcoder.udroid.x11.OverlayKeyboard +import org.randomcoder.udroid.x11.OverlayKeyState import kotlin.math.roundToInt internal class X11DesktopHostView(context: Context) : FrameLayout(context) { val displayView = X11DisplayView(context) private val density = resources.displayMetrics.density + private val keyboardStripHeight = dp(76) private val palette = LinearLayout(context) + private val keyboardStrip = LinearLayout(context) + private val displayKeyLayer = FrameLayout(context) + private val keyboardButtons = mutableListOf() + private val displayKeyButtons = mutableListOf() + private val keyboardState = OverlayKeyState { binding, pressed -> + when (binding) { + OverlayKeyboard.MOUSE_LEFT -> + displayView.setOverlayMouseButton(InputStub.BUTTON_LEFT, pressed) + OverlayKeyboard.MOUSE_MIDDLE -> + displayView.setOverlayMouseButton(InputStub.BUTTON_MIDDLE, pressed) + OverlayKeyboard.MOUSE_RIGHT -> + displayView.setOverlayMouseButton(InputStub.BUTTON_RIGHT, pressed) + OverlayKeyboard.SCROLL_UP -> if (pressed) displayView.scrollMouse(120f) + OverlayKeyboard.SCROLL_DOWN -> if (pressed) displayView.scrollMouse(-120f) + else -> displayView.setOverlayKey(binding, pressed) + } + } + private val heldKeyboardSlots = mutableSetOf() + private val chordUsedSlots = mutableSetOf() + private val modifierTaps = ModifierDoubleTap(ViewConfiguration.getDoubleTapTimeout().toLong()) + private var keyboardBindings = OverlayKeyboard.defaults + private var displayKeys = emptyList() + private var displayKeysVisible = false + private var arrangingDisplayKeys = false + var onDisplayKeysMoved: ((List) -> Unit)? = null private val mouseButtons = mutableMapOf() private val scrollButtons = mutableListOf() private val paletteTextViews = mutableListOf() @@ -47,6 +76,28 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { }, ) palette.visibility = View.GONE + displayKeyLayer.isMotionEventSplittingEnabled = true + addView( + displayKeyLayer, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT), + ) + displayKeyLayer.visibility = View.GONE + buildKeyboardStrip() + addView( + keyboardStrip, + LayoutParams(LayoutParams.MATCH_PARENT, keyboardStripHeight).apply { gravity = Gravity.BOTTOM }, + ) + keyboardStrip.visibility = View.GONE + displayKeyLayer.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> positionDisplayKeys() } + displayView.setOnInputReset { + keyboardState.clear() + heldKeyboardSlots.clear() + chordUsedSlots.clear() + modifierTaps.clear() + updateKeyboardButtons() + releaseMouseButtons() + } + displayView.setOnCompanionInput(::markKeyboardChordUsed) } fun setPaletteVisible(visible: Boolean) { @@ -54,6 +105,70 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { palette.visibility = if (visible) View.VISIBLE else View.GONE } + fun setKeyboardStripVisible(visible: Boolean) { + if ((keyboardStrip.visibility == View.VISIBLE) == visible) return + if (!visible && keyboardStrip.visibility == View.VISIBLE) releaseKeyboard() + keyboardStrip.visibility = if (visible) View.VISIBLE else View.GONE + val bottomMargin = if (visible) keyboardStripHeight else 0 + (displayView.layoutParams as LayoutParams).also { + it.bottomMargin = bottomMargin + displayView.layoutParams = it + } + (displayKeyLayer.layoutParams as LayoutParams).also { + it.bottomMargin = bottomMargin + displayKeyLayer.layoutParams = it + } + } + + fun setDisplayKeysVisible(visible: Boolean) { + if (!visible && displayKeysVisible) releaseKeyboard() + displayKeysVisible = visible + displayKeyLayer.visibility = if (visible) View.VISIBLE else View.GONE + } + + fun setDisplayKeysArranging(arranging: Boolean) { + if (arranging == arrangingDisplayKeys) return + releaseKeyboard() + arrangingDisplayKeys = arranging + displayKeyButtons.forEach { it.isActivated = arranging } + updateKeyboardButtons() + } + + fun setDisplayKeys(keys: List) { + val normalized = OverlayKeyboard.normalizedDisplayKeys(keys) + if (normalized == displayKeys) return + releaseKeyboard() + displayKeys = normalized + displayKeyLayer.removeAllViews() + displayKeyButtons.clear() + normalized.forEachIndexed { index, key -> + val label = OverlayKeyboard.label(key.keyCode) + val button = textButton(label, "Hold $label key; arrange to move").apply { + textSize = 12f + background = displayKeyBackground(false) + isActivated = arrangingDisplayKeys + } + installKeyboardButton(button, 1000 + index) + displayKeyButtons += button + displayKeyLayer.addView(button, FrameLayout.LayoutParams(dp(52), dp(52))) + } + positionDisplayKeys() + updateKeyboardButtons() + } + + fun setKeyboardBindings(bindings: List) { + val normalized = OverlayKeyboard.normalized(bindings) + if (normalized == keyboardBindings) return + releaseKeyboard() + keyboardBindings = normalized + keyboardButtons.forEachIndexed { index, view -> + val label = OverlayKeyboard.label(normalized[index]) + view.text = label + view.contentDescription = "Hold $label key" + } + updateKeyboardButtons() + } + fun setPaletteColors( primaryContainer: Int, surfaceContainer: Int, @@ -65,9 +180,11 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { this.onSurface = onSurface this.outline = outline palette.background = roundedBackground(surfaceContainer, outline, 30) + keyboardStrip.setBackgroundColor(surfaceContainer) paletteTextViews.forEach { it.setTextColor(onSurface) } mouseButtons.forEach { (button, view) -> updateMouseButton(view, button) } scrollButtons.forEach { it.background = mouseButtonBackground(true) } + updateKeyboardButtons() } private fun buildPalette() { @@ -96,7 +213,7 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { lock.contentDescription = if (paletteLocked) "Unlock mouse controls" else "Lock mouse controls" } - installPaletteDrag(header) + installPaletteDrag(header, palette) { paletteLocked } val mouseBody = LinearLayout(context).apply { @@ -126,29 +243,58 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { palette.addView(mouseBody) } - private fun installPaletteDrag(header: View) { + private fun buildKeyboardStrip() { + keyboardStrip.orientation = LinearLayout.VERTICAL + keyboardStrip.isMotionEventSplittingEnabled = true + keyboardStrip.setBackgroundColor(surfaceContainer) + keyboardStrip.setPadding(dp(4), dp(2), dp(4), dp(2)) + repeat(2) { rowIndex -> + val row = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + isMotionEventSplittingEnabled = true + } + repeat(6) { columnIndex -> + val slot = rowIndex * 6 + columnIndex + val label = OverlayKeyboard.label(keyboardBindings[slot]) + val button = textButton(label, "Hold $label key").apply { + textSize = 12f + background = stripButtonBackground(false) + } + installKeyboardButton(button, slot) + keyboardButtons += button + row.addView(button, LinearLayout.LayoutParams(0, dp(36), 1f)) + } + keyboardStrip.addView(row, LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, dp(36))) + } + } + + private fun installPaletteDrag( + header: View, + targetPalette: View, + isLocked: () -> Boolean, + ) { var startRawX = 0f var startRawY = 0f var startX = 0f var startY = 0f header.setOnTouchListener { _, event -> - if (paletteLocked) return@setOnTouchListener false + if (isLocked()) return@setOnTouchListener true when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { startRawX = event.rawX startRawY = event.rawY - startX = palette.x - startY = palette.y + startX = targetPalette.x + startY = targetPalette.y true } MotionEvent.ACTION_MOVE -> { - palette.x = + targetPalette.x = (startX + event.rawX - startRawX) - .coerceIn(0f, (width - palette.width).coerceAtLeast(0).toFloat()) - palette.y = + .coerceIn(0f, (width - targetPalette.width).coerceAtLeast(0).toFloat()) + targetPalette.y = (startY + event.rawY - startRawY) - .coerceIn(0f, (height - palette.height).coerceAtLeast(0).toFloat()) + .coerceIn(0f, (height - targetPalette.height).coerceAtLeast(0).toFloat()) true } @@ -174,6 +320,7 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { view.setOnTouchListener { target, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { + markKeyboardChordUsed() target.isPressed = true momentaryPress = false target.postDelayed( @@ -214,6 +361,199 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { } } + private fun installKeyboardButton(view: TextView, slot: Int) { + var downTime = 0L + var handledTouchClick = false + var startRawX = 0f + var startRawY = 0f + var startX = 0f + var startY = 0f + view.setOnClickListener { + if (handledTouchClick) { + handledTouchClick = false + } else if (displayView.isRendererAttached) { + val keyCode = bindingForSlot(slot) + modifierTaps.clear() + keyboardState.press(touchOwner(slot), keyCode) + keyboardState.release(touchOwner(slot)) + updateKeyboardButtons() + } + } + view.setOnLongClickListener { + if (!displayView.isRendererAttached || !OverlayKeyboard.isModifier(bindingForSlot(slot))) { + false + } else { + modifierTaps.clear() + toggleLatchedKey(slot) + updateKeyboardButtons() + true + } + } + view.setOnTouchListener { target, event -> + if (slot >= 1000 && arrangingDisplayKeys) { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + startRawX = event.rawX + startRawY = event.rawY + startX = target.x + startY = target.y + true + } + MotionEvent.ACTION_MOVE -> { + target.x = (startX + event.rawX - startRawX) + .coerceIn(0f, (displayKeyLayer.width - target.width).coerceAtLeast(0).toFloat()) + target.y = (startY + event.rawY - startRawY) + .coerceIn(0f, (displayKeyLayer.height - target.height).coerceAtLeast(0).toFloat()) + true + } + MotionEvent.ACTION_UP -> { + val index = slot - 1000 + val moved = displayKeys.toMutableList() + moved[index] = moved[index].copy( + x = target.x / (displayKeyLayer.width - target.width).coerceAtLeast(1), + y = target.y / (displayKeyLayer.height - target.height).coerceAtLeast(1), + ) + displayKeys = moved + onDisplayKeysMoved?.invoke(moved) + true + } + MotionEvent.ACTION_CANCEL -> { + positionDisplayKeys() + true + } + else -> true + } + } else { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + if (!displayView.isRendererAttached) return@setOnTouchListener true + modifierTaps.cancelUnless(slot) + if (heldKeyboardSlots.isNotEmpty()) { + chordUsedSlots.addAll(heldKeyboardSlots) + chordUsedSlots.add(slot) + modifierTaps.clear() + } + heldKeyboardSlots.add(slot) + downTime = event.eventTime + target.isPressed = true + keyboardState.press(touchOwner(slot), bindingForSlot(slot)) + updateKeyboardButtons() + true + } + + MotionEvent.ACTION_UP -> { + target.isPressed = false + val keyCode = bindingForSlot(slot) + if (isQuickModifierTap( + isHeld = slot in heldKeyboardSlots, + isModifier = OverlayKeyboard.isModifier(keyCode), + usedInChord = slot in chordUsedSlots, + heldMillis = event.eventTime - downTime, + tapThresholdMillis = ViewConfiguration.getLongPressTimeout().toLong(), + )) { + if (keyboardState.isPressed(latchOwner(slot))) { + toggleLatchedKey(slot) + modifierTaps.clear() + } else if (modifierTaps.tapped(slot, event.eventTime)) { + toggleLatchedKey(slot) + } + } else { + modifierTaps.clear() + } + keyboardState.release(touchOwner(slot)) + heldKeyboardSlots.remove(slot) + chordUsedSlots.remove(slot) + updateKeyboardButtons() + handledTouchClick = true + target.performClick() + true + } + + MotionEvent.ACTION_CANCEL -> { + target.isPressed = false + modifierTaps.clear() + keyboardState.release(touchOwner(slot)) + heldKeyboardSlots.remove(slot) + chordUsedSlots.remove(slot) + updateKeyboardButtons() + true + } + + else -> true + } + } + } + } + + private fun bindingForSlot(slot: Int): Int = + if (slot >= 1000) displayKeys[slot - 1000].keyCode else keyboardBindings[slot] + + private fun positionDisplayKeys() { + displayKeyButtons.forEachIndexed { index, button -> + button.x = displayKeys[index].x * (displayKeyLayer.width - dp(52)).coerceAtLeast(0) + button.y = displayKeys[index].y * (displayKeyLayer.height - dp(52)).coerceAtLeast(0) + } + } + + private fun toggleLatchedKey(slot: Int) { + val owner = latchOwner(slot) + if (keyboardState.isPressed(owner)) { + keyboardState.release(owner) + } else { + keyboardState.press(owner, bindingForSlot(slot)) + } + } + + private fun markKeyboardChordUsed() { + chordUsedSlots.addAll(heldKeyboardSlots) + modifierTaps.clear() + } + + private fun releaseKeyboard() { + keyboardState.releaseAll() + heldKeyboardSlots.clear() + chordUsedSlots.clear() + modifierTaps.clear() + updateKeyboardButtons() + } + + private fun updateKeyboardButtons() { + (keyboardButtons.mapIndexed { slot, view -> slot to view } + + displayKeyButtons.mapIndexed { index, view -> (1000 + index) to view }).forEach { (slot, view) -> + val active = keyboardState.isPressed(touchOwner(slot)) || keyboardState.isPressed(latchOwner(slot)) + view.isLongClickable = !arrangingDisplayKeys && OverlayKeyboard.isModifier(bindingForSlot(slot)) + if (slot >= 1000) { + val label = OverlayKeyboard.label(bindingForSlot(slot)) + view.contentDescription = if (arrangingDisplayKeys) "Drag $label to move" else "Hold $label key" + } + view.background = + if (slot >= 1000) displayKeyBackground(active) else stripButtonBackground(active) + } + } + + private fun stripButtonBackground(active: Boolean): StateListDrawable = + StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_pressed), roundedBackground(primaryContainer, Color.TRANSPARENT, 6)) + addState(intArrayOf(), roundedBackground(if (active) primaryContainer else Color.TRANSPARENT, Color.TRANSPARENT, 6)) + } + + private fun displayKeyBackground(active: Boolean): StateListDrawable = + StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_pressed), roundedBackground(primaryContainer, onSurface, 12)) + addState( + intArrayOf(), + roundedBackground( + if (active || arrangingDisplayKeys) primaryContainer else Color.argb(40, 0, 0, 0), + onSurface, + 12, + ), + ) + } + + private fun touchOwner(slot: Int) = slot * 2 + + private fun latchOwner(slot: Int) = slot * 2 + 1 + private fun toggleMouseButton(button: Int) { val next = nextLatchedMouseButton(latchedButton, button) releaseMouseButtons() @@ -245,7 +585,10 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { private fun scrollButton(label: String, amount: Float) = textButton(label, if (amount > 0) "Scroll up" else "Scroll down").apply { - setOnClickListener { displayView.scrollMouse(amount) } + setOnClickListener { + markKeyboardChordUsed() + displayView.scrollMouse(amount) + } background = mouseButtonBackground(true) scrollButtons += this } @@ -282,10 +625,52 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { private fun dp(value: Int): Int = (value * density).roundToInt() override fun onDetachedFromWindow() { + releaseKeyboard() releaseMouseButtons() super.onDetachedFromWindow() } + + override fun onWindowFocusChanged(hasWindowFocus: Boolean) { + super.onWindowFocusChanged(hasWindowFocus) + if (!hasWindowFocus) { + releaseKeyboard() + releaseMouseButtons() + } + } } internal fun nextLatchedMouseButton(current: Int, tapped: Int): Int = if (current == tapped) InputStub.BUTTON_UNDEFINED else tapped + +internal fun isQuickModifierTap( + isHeld: Boolean, + isModifier: Boolean, + usedInChord: Boolean, + heldMillis: Long, + tapThresholdMillis: Long, +): Boolean = + isHeld && isModifier && !usedInChord && + heldMillis < tapThresholdMillis + +internal class ModifierDoubleTap(private val timeoutMillis: Long) { + private var slot = -1 + private var time = 0L + + fun tapped(tappedSlot: Int, atMillis: Long): Boolean { + val doubled = slot == tappedSlot && atMillis - time in 0..timeoutMillis + if (doubled) clear() else { + slot = tappedSlot + time = atMillis + } + return doubled + } + + fun cancelUnless(activeSlot: Int) { + if (slot != activeSlot) clear() + } + + fun clear() { + slot = -1 + time = 0L + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/ui/X11SettingsDialog.kt b/app/src/main/java/org/randomcoder/udroid/ui/X11SettingsDialog.kt index d2f8b90..5da5521 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/X11SettingsDialog.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/X11SettingsDialog.kt @@ -9,15 +9,20 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow @@ -25,7 +30,13 @@ import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily @@ -36,14 +47,21 @@ import org.randomcoder.udroid.x11.X11DisplayFilter import org.randomcoder.udroid.x11.X11ResolutionMode import org.randomcoder.udroid.x11.X11Settings import org.randomcoder.udroid.x11.X11TouchMode +import org.randomcoder.udroid.x11.DisplayKey +import org.randomcoder.udroid.x11.OverlayKeyboard import kotlin.math.roundToInt @Composable fun X11SettingsDialog( settings: X11Settings, onSettingsChanged: (X11Settings) -> Unit, + onArrangeDisplayKeys: () -> Unit, onDismiss: () -> Unit, ) { + var editingSlot by remember { mutableIntStateOf(-1) } + var editingDisplayKey by remember { mutableIntStateOf(-1) } + var addingDisplayKey by remember { mutableStateOf(false) } + var keySearch by remember { mutableStateOf("") } Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), @@ -210,6 +228,102 @@ fun X11SettingsDialog( onSettingsChanged(settings.copy(preferScancodes = it)) }, ) + SettingsSwitch( + title = "Keep desktop above keyboard", + subtitle = + "Resize the Linux display when the Android keyboard opens. " + + "Turn off to let the keyboard cover the desktop.", + checked = settings.avoidSoftKeyboard, + onCheckedChange = { + onSettingsChanged(settings.copy(avoidSoftKeyboard = it)) + }, + ) + SettingsLabel( + title = "Keyboard strip", + subtitle = + "These keys stay below Linux and move above the Android keyboard when it opens. " + + "Hold for chords; double-tap a modifier to lock it.", + ) + SettingsSwitch( + title = "Show keyboard strip", + subtitle = "Hide the two rows to give Linux more room", + checked = settings.showKeyboardStrip, + onCheckedChange = { + onSettingsChanged(settings.copy(showKeyboardStrip = it)) + }, + ) + OverlayKeyboard.normalized(settings.overlayKeys).chunked(4) + .forEachIndexed { row, bindings -> + Row(modifier = Modifier.fillMaxWidth()) { + bindings.forEachIndexed { column, keyCode -> + val slot = row * 4 + column + OutlinedButton( + onClick = { + keySearch = "" + editingSlot = slot + }, + modifier = Modifier.weight(1f).padding(horizontal = 2.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(2.dp), + ) { + Text( + OverlayKeyboard.label(keyCode), + maxLines = 1, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + TextButton( + onClick = { + onSettingsChanged(settings.copy(overlayKeys = OverlayKeyboard.defaults)) + }, + ) { + Text("Reset keyboard strip") + } + + SettingsLabel( + title = "Display buttons", + subtitle = + "Place individual keys over Linux for shortcuts or game controls. " + + "They work without opening the Android keyboard. " + + "Use Arrange on display to move them without pressing a key.", + ) + settings.displayKeys.forEachIndexed { index, key -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = OverlayKeyboard.label(key.keyCode), + modifier = Modifier.weight(1f).padding(start = 12.dp), + ) + TextButton(onClick = { editingDisplayKey = index }) { + Text("Change") + } + TextButton( + onClick = { + onSettingsChanged( + settings.copy( + displayKeys = settings.displayKeys.filterIndexed { i, _ -> i != index }, + ), + ) + }, + ) { + Text("Remove") + } + } + } + Row { + TextButton( + onClick = { addingDisplayKey = true }, + enabled = settings.displayKeys.size < 32, + ) { + Text("Add button") + } + if (settings.displayKeys.isNotEmpty()) { + TextButton(onClick = onArrangeDisplayKeys) { + Text("Arrange on display") + } + } + } SettingsDivider() SettingsSectionTitle("Session") @@ -237,6 +351,75 @@ fun X11SettingsDialog( } } } + + if (editingSlot >= 0 || editingDisplayKey >= 0 || addingDisplayKey) { + UdroidTerminalTheme { + AlertDialog( + onDismissRequest = { + editingSlot = -1 + editingDisplayKey = -1 + addingDisplayKey = false + }, + title = { Text("Choose key") }, + text = { + Column { + OutlinedTextField( + value = keySearch, + onValueChange = { keySearch = it }, + label = { Text("Search keys") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) { + items( + OverlayKeyboard.keys.filter { + OverlayKeyboard.label(it).contains(keySearch, ignoreCase = true) + }, + ) { keyCode -> + Text( + text = OverlayKeyboard.label(keyCode), + modifier = + Modifier.fillMaxWidth().clickable { + when { + editingSlot >= 0 -> { + val bindings = OverlayKeyboard.normalized(settings.overlayKeys).toMutableList() + bindings[editingSlot] = keyCode + onSettingsChanged(settings.copy(overlayKeys = bindings)) + } + editingDisplayKey >= 0 -> { + val buttons = settings.displayKeys.toMutableList() + buttons[editingDisplayKey] = buttons[editingDisplayKey].copy(keyCode = keyCode) + onSettingsChanged(settings.copy(displayKeys = buttons)) + } + addingDisplayKey -> { + val index = settings.displayKeys.size + onSettingsChanged( + settings.copy( + displayKeys = settings.displayKeys + + DisplayKey(keyCode, 0.12f + (index % 4) * 0.2f, 0.4f + (index / 4) * 0.08f), + ), + ) + } + } + editingSlot = -1 + editingDisplayKey = -1 + addingDisplayKey = false + }.padding(12.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { + editingSlot = -1 + editingDisplayKey = -1 + addingDisplayKey = false + }) { Text("Cancel") } + }, + ) + } + } } @Composable diff --git a/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostRuntime.kt b/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostRuntime.kt index c0d93ed..66ab002 100644 --- a/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostRuntime.kt +++ b/app/src/main/java/org/randomcoder/udroid/virgl/VirglHostRuntime.kt @@ -20,7 +20,7 @@ internal data class VenusGuestRuntime( ) internal object VirglHostRuntimeInstaller { - const val VERSION = "1.3.0-6" + const val VERSION = "1.3.0-7" private val bundle = VerifiedRuntimeAssetBundle( name = "VirGL host", @@ -92,6 +92,7 @@ internal object VenusGuestRuntimeInstaller { version = VERSION, entries = listOf( + "bin/zink_check_requirements", "lib/dri/zink_dri.so", "lib/libEGL_mesa.so.0", "lib/libGLX_mesa.so.0", @@ -102,6 +103,7 @@ internal object VenusGuestRuntimeInstaller { "share/glvnd/egl_vendor.d/50_mesa.json", "share/vulkan/icd.d/virtio_icd.aarch64.json", ), + executables = setOf("bin/zink_check_requirements"), metadataKeys = setOf("mesa_commit"), ) @@ -129,3 +131,34 @@ internal object VenusGuestRuntimeInstaller { return values["ID"] == "ubuntu" && values["VERSION_ID"] in setOf("22.04", "24.04") } } + +internal data class ZinkRequirementResult( + val profile: String, + val supported: Boolean, +) { + val label: String = + profile + .removePrefix("VP_ZINK_") + .replace(Regex("^gl(\\d)(\\d)_"), "OpenGL $1.$2 ") + .replace('_', ' ') +} + +internal fun parseZinkRequirements(output: String): List { + var profile: String? = null + return buildList { + output.lineSequence().forEach { line -> + when { + line.startsWith("Checking profile ") -> + profile = line.removePrefix("Checking profile ").trim() + line == "Supported" -> { + profile?.let { add(ZinkRequirementResult(it, true)) } + profile = null + } + line.startsWith("UNSUPPORTED") -> { + profile?.let { add(ZinkRequirementResult(it, false)) } + profile = null + } + } + } + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/virgl/VirglProotLaunchProfile.kt b/app/src/main/java/org/randomcoder/udroid/virgl/VirglProotLaunchProfile.kt index ecda65c..b2e3b99 100644 --- a/app/src/main/java/org/randomcoder/udroid/virgl/VirglProotLaunchProfile.kt +++ b/app/src/main/java/org/randomcoder/udroid/virgl/VirglProotLaunchProfile.kt @@ -40,8 +40,9 @@ internal data class VirglProotLaunchProfile( "GALLIUM_DRIVER", "-u", "MESA_LOADER_DRIVER_OVERRIDE", + "-u", + "MESA_VK_WSI_DEBUG", "VN_DEBUG=vtest", - "MESA_VK_WSI_DEBUG=sw", ) + if (venusGuestRuntime != null) { listOf( diff --git a/app/src/main/java/org/randomcoder/udroid/x11/MouseButtonState.java b/app/src/main/java/org/randomcoder/udroid/x11/MouseButtonState.java new file mode 100644 index 0000000..05d7263 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/x11/MouseButtonState.java @@ -0,0 +1,44 @@ +package org.randomcoder.udroid.x11; + +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Set; + +final class MouseButtonState { + enum Source { DIRECT, PHYSICAL, PALETTE, OVERLAY } + + private final EnumMap> buttons = new EnumMap<>(Source.class); + + MouseButtonState() { + for (Source source : Source.values()) buttons.put(source, new HashSet<>()); + } + + /** Returns the new effective state only when the X button state changed. */ + Boolean set(Source source, int button, boolean pressed) { + boolean before = isDown(button); + if (pressed) buttons.get(source).add(button); + else buttons.get(source).remove(button); + boolean after = isDown(button); + return before == after ? null : after; + } + + boolean isDown(int button) { + for (Set source : buttons.values()) { + if (source.contains(button)) return true; + } + return false; + } + + boolean hasExternalButton() { + return !buttons.get(Source.PALETTE).isEmpty() || + !buttons.get(Source.OVERLAY).isEmpty(); + } + + Set pressedBy(Source source) { + return new HashSet<>(buttons.get(source)); + } + + void clear() { + for (Set source : buttons.values()) source.clear(); + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/x11/OverlayKeyboard.kt b/app/src/main/java/org/randomcoder/udroid/x11/OverlayKeyboard.kt new file mode 100644 index 0000000..bef2d14 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/x11/OverlayKeyboard.kt @@ -0,0 +1,120 @@ +package org.randomcoder.udroid.x11 + +import android.view.KeyEvent + +data class DisplayKey(val keyCode: Int, val x: Float, val y: Float) + +object OverlayKeyboard { + const val MOUSE_LEFT = -1 + const val MOUSE_MIDDLE = -2 + const val MOUSE_RIGHT = -3 + const val SCROLL_UP = -4 + const val SCROLL_DOWN = -5 + + val keys: List = + listOf( + MOUSE_LEFT, MOUSE_MIDDLE, MOUSE_RIGHT, SCROLL_UP, SCROLL_DOWN, + KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT, + KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT, + KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT, + KeyEvent.KEYCODE_META_LEFT, KeyEvent.KEYCODE_META_RIGHT, + KeyEvent.KEYCODE_ESCAPE, KeyEvent.KEYCODE_TAB, KeyEvent.KEYCODE_SPACE, + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_FORWARD_DEL, + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_MOVE_HOME, KeyEvent.KEYCODE_MOVE_END, + KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_PAGE_DOWN, + KeyEvent.KEYCODE_INSERT, KeyEvent.KEYCODE_MINUS, + KeyEvent.KEYCODE_EQUALS, KeyEvent.KEYCODE_LEFT_BRACKET, + KeyEvent.KEYCODE_RIGHT_BRACKET, KeyEvent.KEYCODE_BACKSLASH, + KeyEvent.KEYCODE_SEMICOLON, KeyEvent.KEYCODE_APOSTROPHE, + KeyEvent.KEYCODE_COMMA, KeyEvent.KEYCODE_PERIOD, KeyEvent.KEYCODE_SLASH, + ) + + (KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z).toList() + + (KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9).toList() + + (KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12).toList() + + val defaults: List = + listOf( + KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_ALT_LEFT, + KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_META_LEFT, + KeyEvent.KEYCODE_ESCAPE, KeyEvent.KEYCODE_TAB, + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_MOVE_HOME, KeyEvent.KEYCODE_MOVE_END, + ) + + fun normalized(saved: List): List = + defaults.indices.map { index -> + saved.getOrNull(index)?.takeIf { it in keys } ?: defaults[index] + } + + fun normalizedDisplayKeys(saved: List): List = + saved.take(32).mapNotNull { key -> + key.takeIf { it.keyCode in keys && it.x.isFinite() && it.y.isFinite() } + ?.copy(x = key.x.coerceIn(0f, 1f), y = key.y.coerceIn(0f, 1f)) + } + + fun isModifier(keyCode: Int): Boolean = + keyCode in KeyEvent.KEYCODE_CTRL_LEFT..KeyEvent.KEYCODE_CTRL_RIGHT || + keyCode in KeyEvent.KEYCODE_ALT_LEFT..KeyEvent.KEYCODE_ALT_RIGHT || + keyCode in KeyEvent.KEYCODE_SHIFT_LEFT..KeyEvent.KEYCODE_SHIFT_RIGHT || + keyCode in KeyEvent.KEYCODE_META_LEFT..KeyEvent.KEYCODE_META_RIGHT + + fun label(keyCode: Int): String = + when (keyCode) { + MOUSE_LEFT -> "Mouse L" + MOUSE_MIDDLE -> "Mouse M" + MOUSE_RIGHT -> "Mouse R" + SCROLL_UP -> "Scroll ↑" + SCROLL_DOWN -> "Scroll ↓" + KeyEvent.KEYCODE_CTRL_LEFT -> "Ctrl" + KeyEvent.KEYCODE_CTRL_RIGHT -> "R Ctrl" + KeyEvent.KEYCODE_ALT_LEFT -> "Alt" + KeyEvent.KEYCODE_ALT_RIGHT -> "R Alt" + KeyEvent.KEYCODE_SHIFT_LEFT -> "Shift" + KeyEvent.KEYCODE_SHIFT_RIGHT -> "R Shift" + KeyEvent.KEYCODE_META_LEFT -> "Super" + KeyEvent.KEYCODE_META_RIGHT -> "R Super" + KeyEvent.KEYCODE_ESCAPE -> "Esc" + KeyEvent.KEYCODE_DEL -> "Backspace" + KeyEvent.KEYCODE_FORWARD_DEL -> "Delete" + KeyEvent.KEYCODE_DPAD_UP -> "↑" + KeyEvent.KEYCODE_DPAD_LEFT -> "←" + KeyEvent.KEYCODE_DPAD_DOWN -> "↓" + KeyEvent.KEYCODE_DPAD_RIGHT -> "→" + KeyEvent.KEYCODE_MOVE_HOME -> "Home" + KeyEvent.KEYCODE_MOVE_END -> "End" + KeyEvent.KEYCODE_PAGE_UP -> "PgUp" + KeyEvent.KEYCODE_PAGE_DOWN -> "PgDn" + else -> KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_") + } +} + +/** Tracks each touch/latch separately so duplicate bindings release only after their last owner. */ +internal class OverlayKeyState(private val send: (Int, Boolean) -> Unit) { + private val owners = mutableMapOf() + + fun press(owner: Int, keyCode: Int) { + if (owners[owner] == keyCode) return + release(owner) + if (keyCode !in owners.values) send(keyCode, true) + owners[owner] = keyCode + } + + fun release(owner: Int) { + val keyCode = owners.remove(owner) ?: return + if (keyCode !in owners.values) send(keyCode, false) + } + + fun isPressed(owner: Int): Boolean = owner in owners + + fun releaseAll() { + owners.values.toSet().forEach { send(it, false) } + owners.clear() + } + + fun clear() { + owners.clear() + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java b/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java index 9e7f945..a0c5794 100644 --- a/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java +++ b/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java @@ -20,6 +20,8 @@ import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.annotation.NonNull; @@ -28,6 +30,9 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Set; +import org.randomcoder.udroid.x11.MouseButtonState.Source; /** * Minimal uDroid-owned Android surface for the Termux:X11 renderer. @@ -50,8 +55,10 @@ public final class X11DisplayView extends SurfaceView private boolean surfaceAvailable; private boolean imeHasCommittedText; private CharSequence composingText; - private int pressedMouseButton = BUTTON_UNDEFINED; - private int paletteMouseButton = BUTTON_UNDEFINED; + private final MouseButtonState mouseButtons = new MouseButtonState(); + private final Set overlayKeysDown = new HashSet<>(); + private Runnable onInputReset; + private Runnable onCompanionInput; private int viewportLeft; private int viewportTop; private int viewportWidth; @@ -276,7 +283,25 @@ public boolean dispatchKeyEvent(KeyEvent event) { return super.dispatchKeyEvent(event); } if (rendererAttached) { - return inputSender.sendKeyEvent(event); + if (event.getAction() == KeyEvent.ACTION_DOWN && onCompanionInput != null) { + onCompanionInput.run(); + } + boolean usePhysicalKeyEvents = + overlayKeysDown.contains(KeyEvent.KEYCODE_CTRL_LEFT) || + overlayKeysDown.contains(KeyEvent.KEYCODE_CTRL_RIGHT) || + overlayKeysDown.contains(KeyEvent.KEYCODE_ALT_LEFT) || + overlayKeysDown.contains(KeyEvent.KEYCODE_ALT_RIGHT) || + overlayKeysDown.contains(KeyEvent.KEYCODE_SHIFT_LEFT) || + overlayKeysDown.contains(KeyEvent.KEYCODE_SHIFT_RIGHT) || + overlayKeysDown.contains(KeyEvent.KEYCODE_META_LEFT) || + overlayKeysDown.contains(KeyEvent.KEYCODE_META_RIGHT); + boolean preferScancodes = inputSender.preferScancodes; + if (usePhysicalKeyEvents) inputSender.preferScancodes = true; + try { + return inputSender.sendKeyEvent(event); + } finally { + inputSender.preferScancodes = preferScancodes; + } } return super.dispatchKeyEvent(event); } @@ -291,6 +316,9 @@ public boolean onTouchEvent(MotionEvent event) { } int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN && onCompanionInput != null) { + onCompanionInput.run(); + } if (settings.getTouchMode() == X11TouchMode.TRACKPAD) { return handleTrackpadTouch(event); } @@ -314,8 +342,8 @@ public boolean onTouchEvent(MotionEvent event) { false, false ); - if (paletteMouseButton == BUTTON_UNDEFINED) { - inputSink.sendMouseEvent(0, 0, BUTTON_LEFT, true, false); + if (!mouseButtons.hasExternalButton()) { + setDirectTouchLeft(true); } return true; case MotionEvent.ACTION_MOVE: @@ -336,9 +364,7 @@ public boolean onTouchEvent(MotionEvent event) { false, false ); - if (paletteMouseButton == BUTTON_UNDEFINED) { - releaseTouchButton(); - } + releaseTouchButton(); performClick(); return true; case MotionEvent.ACTION_CANCEL: @@ -458,20 +484,55 @@ public void showKeyboard() { post(() -> inputMethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)); } + public void hideKeyboard() { + var controller = ViewCompat.getWindowInsetsController(this); + if (controller != null) controller.hide(WindowInsetsCompat.Type.ime()); + else inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0); + } + + public void setOnInputReset(Runnable callback) { + onInputReset = callback; + } + + public void setOnCompanionInput(Runnable callback) { + onCompanionInput = callback; + } + + public void setOverlayKey(int keyCode, boolean pressed) { + if (!rendererAttached || !connected()) return; + if (pressed ? overlayKeysDown.add(keyCode) : overlayKeysDown.remove(keyCode)) { + sendKeyEvent(0, keyCode, pressed); + } + requestFocus(); + } + public void setMouseButton(int button, boolean pressed) { + setExternalMouseButton(Source.PALETTE, button, pressed); + } + + public void setOverlayMouseButton(int button, boolean pressed) { + setExternalMouseButton(Source.OVERLAY, button, pressed); + } + + private void setExternalMouseButton(Source source, int button, boolean pressed) { if (!rendererAttached || button < BUTTON_LEFT || button > BUTTON_RIGHT) return; requestFocus(); - inputSink.sendMouseEvent(0, 0, button, pressed, true); - if (pressed) { - paletteMouseButton = button; - } else if (paletteMouseButton == button) { - paletteMouseButton = BUTTON_UNDEFINED; + Boolean changed = mouseButtons.set(source, button, pressed); + if (changed != null) { + inputSink.sendMouseEvent(0, 0, button, changed, source != Source.PHYSICAL); } trackpadGestures.setExternalMouseButtonHeld( - paletteMouseButton != BUTTON_UNDEFINED + mouseButtons.hasExternalButton() ); } + private void setDirectTouchLeft(boolean pressed) { + Boolean changed = mouseButtons.set(Source.DIRECT, BUTTON_LEFT, pressed); + if (changed != null) { + inputSink.sendMouseEvent(0, 0, BUTTON_LEFT, changed, false); + } + } + public void scrollMouse(float verticalDelta) { if (!rendererAttached || verticalDelta == 0) return; requestFocus(); @@ -489,6 +550,7 @@ private boolean handleMouseEvent(MotionEvent event) { ); switch (event.getActionMasked()) { case MotionEvent.ACTION_SCROLL: + if (onCompanionInput != null) onCompanionInput.run(); inputSink.sendMouseWheelEvent( -120f * event.getAxisValue(MotionEvent.AXIS_HSCROLL), 120f * event.getAxisValue(MotionEvent.AXIS_VSCROLL) @@ -496,24 +558,23 @@ private boolean handleMouseEvent(MotionEvent event) { return true; case MotionEvent.ACTION_BUTTON_PRESS: case MotionEvent.ACTION_DOWN: + if (onCompanionInput != null) onCompanionInput.run(); int pressed = mapButton(event); - if (pressed != BUTTON_UNDEFINED && pressedMouseButton != pressed) { - inputSink.sendMouseEvent(0, 0, pressed, true, false); - pressedMouseButton = pressed; + if (pressed != BUTTON_UNDEFINED) { + setExternalMouseButton(Source.PHYSICAL, pressed, true); } return true; case MotionEvent.ACTION_BUTTON_RELEASE: + int released = event.getActionButton() == 0 + ? BUTTON_UNDEFINED : mapButton(event); + if (released != BUTTON_UNDEFINED) { + setExternalMouseButton(Source.PHYSICAL, released, false); + } + return true; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: - if (pressedMouseButton != BUTTON_UNDEFINED) { - inputSink.sendMouseEvent( - 0, - 0, - pressedMouseButton, - false, - false - ); - pressedMouseButton = BUTTON_UNDEFINED; + for (int button : mouseButtons.pressedBy(Source.PHYSICAL)) { + setExternalMouseButton(Source.PHYSICAL, button, false); } return true; default: @@ -554,16 +615,20 @@ else if ((state & MotionEvent.BUTTON_SECONDARY) != 0) { } private void releaseTouchButton() { - inputSink.sendMouseEvent(0, 0, BUTTON_LEFT, false, false); + setDirectTouchLeft(false); } private void releaseAllInput() { + for (int keyCode : overlayKeysDown) { + if (rendererAttached && connected()) sendKeyEvent(0, keyCode, false); + } + overlayKeysDown.clear(); trackpadGestures.cancel(); trackpadGestures.setExternalMouseButtonHeld(false); nativeTouches.cancel(); inputSink.releaseAllInput(); - pressedMouseButton = BUTTON_UNDEFINED; - paletteMouseButton = BUTTON_UNDEFINED; + mouseButtons.clear(); + if (onInputReset != null) onInputReset.run(); } @Override diff --git a/app/src/main/java/org/randomcoder/udroid/x11/X11ServerService.kt b/app/src/main/java/org/randomcoder/udroid/x11/X11ServerService.kt index a692938..53e38e5 100644 --- a/app/src/main/java/org/randomcoder/udroid/x11/X11ServerService.kt +++ b/app/src/main/java/org/randomcoder/udroid/x11/X11ServerService.kt @@ -14,6 +14,7 @@ import android.util.Log import java.io.File import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean +import org.randomcoder.udroid.BuildConfig class X11ServerService : Service() { private val monitorExecutor = @@ -142,6 +143,7 @@ class X11ServerService : Service() { Os.setenv("TMPDIR", runtime.absolutePath, true) Os.setenv("XKB_CONFIG_ROOT", xkbRoot.canonicalPath, true) Os.setenv("HOME", filesRoot.absolutePath, true) + if (BuildConfig.DEBUG) Os.setenv("TERMUX_X11_DEBUG", "1", true) } private fun monitorSocket( diff --git a/app/src/main/java/org/randomcoder/udroid/x11/X11Settings.kt b/app/src/main/java/org/randomcoder/udroid/x11/X11Settings.kt index 058ebf1..f1f98fb 100644 --- a/app/src/main/java/org/randomcoder/udroid/x11/X11Settings.kt +++ b/app/src/main/java/org/randomcoder/udroid/x11/X11Settings.kt @@ -1,6 +1,8 @@ package org.randomcoder.udroid.x11 import android.content.Context +import org.json.JSONArray +import org.json.JSONException import kotlin.math.roundToInt enum class X11ResolutionMode { @@ -32,6 +34,10 @@ data class X11Settings( val preferScancodes: Boolean = false, val keepScreenOn: Boolean = true, val startControlsCollapsed: Boolean = false, + val overlayKeys: List = OverlayKeyboard.defaults, + val displayKeys: List = emptyList(), + val showKeyboardStrip: Boolean = true, + val avoidSoftKeyboard: Boolean = true, ) data class X11Viewport( @@ -137,6 +143,14 @@ class X11SettingsStore(context: Context) { keepScreenOn = preferences.getBoolean(KEY_KEEP_SCREEN_ON, true), startControlsCollapsed = preferences.getBoolean(KEY_START_CONTROLS_COLLAPSED, false), + overlayKeys = + OverlayKeyboard.normalized( + preferences.getString(KEY_OVERLAY_KEYS, null) + ?.split(',')?.mapNotNull(String::toIntOrNull).orEmpty(), + ), + displayKeys = loadDisplayKeys(preferences.getString(KEY_DISPLAY_KEYS, null)), + showKeyboardStrip = preferences.getBoolean(KEY_SHOW_KEYBOARD_STRIP, true), + avoidSoftKeyboard = preferences.getBoolean(KEY_AVOID_SOFT_KEYBOARD, true), ) fun save(settings: X11Settings): X11Settings { @@ -155,6 +169,22 @@ class X11SettingsStore(context: Context) { .putBoolean( KEY_START_CONTROLS_COLLAPSED, settings.startControlsCollapsed, + ).putString( + KEY_OVERLAY_KEYS, + OverlayKeyboard.normalized(settings.overlayKeys).joinToString(","), + ).putBoolean( + KEY_SHOW_KEYBOARD_STRIP, + settings.showKeyboardStrip, + ).putBoolean( + KEY_AVOID_SOFT_KEYBOARD, + settings.avoidSoftKeyboard, + ).putString( + KEY_DISPLAY_KEYS, + JSONArray().apply { + OverlayKeyboard.normalizedDisplayKeys(settings.displayKeys).forEach { key -> + put(JSONArray().put(key.keyCode).put(key.x.toDouble()).put(key.y.toDouble())) + } + }.toString(), ).apply() return settings } @@ -164,6 +194,20 @@ class X11SettingsStore(context: Context) { fallback: T, ): T = enumValues().firstOrNull { it.name == value } ?: fallback + private fun loadDisplayKeys(raw: String?): List = + try { + val saved = JSONArray(raw ?: "[]") + OverlayKeyboard.normalizedDisplayKeys( + (0 until saved.length()).map { index -> + saved.getJSONArray(index).let { key -> + DisplayKey(key.getInt(0), key.getDouble(1).toFloat(), key.getDouble(2).toFloat()) + } + }, + ) + } catch (_: JSONException) { + emptyList() + } + private companion object { const val PREFERENCES_NAME = "embedded-x11-settings" const val KEY_RESOLUTION_MODE = "resolution-mode" @@ -177,5 +221,9 @@ class X11SettingsStore(context: Context) { const val KEY_PREFER_SCANCODES = "prefer-scancodes" const val KEY_KEEP_SCREEN_ON = "keep-screen-on" const val KEY_START_CONTROLS_COLLAPSED = "start-controls-collapsed" + const val KEY_OVERLAY_KEYS = "overlay-keys" + const val KEY_DISPLAY_KEYS = "display-keys" + const val KEY_SHOW_KEYBOARD_STRIP = "show-keyboard-strip" + const val KEY_AVOID_SOFT_KEYBOARD = "avoid-soft-keyboard" } } diff --git a/app/src/main/jniLibs/arm64-v8a/libproot-loader.so b/app/src/main/jniLibs/arm64-v8a/libproot-loader.so index 27f9fb0..d5b05af 100755 Binary files a/app/src/main/jniLibs/arm64-v8a/libproot-loader.so and b/app/src/main/jniLibs/arm64-v8a/libproot-loader.so differ diff --git a/app/src/main/res/drawable/distro_unknown.xml b/app/src/main/res/drawable/distro_unknown.xml new file mode 100644 index 0000000..fc144b5 --- /dev/null +++ b/app/src/main/res/drawable/distro_unknown.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/test/java/org/randomcoder/udroid/DebugCommandStoreTest.kt b/app/src/test/java/org/randomcoder/udroid/DebugCommandStoreTest.kt new file mode 100644 index 0000000..8bc1e11 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/DebugCommandStoreTest.kt @@ -0,0 +1,15 @@ +package org.randomcoder.udroid + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DebugCommandStoreTest { + @Test + fun acceptsOnlyFixedSupervisorCommands() { + assertTrue(DebugCommandStore.isSupported("start-desktop")) + assertTrue(DebugCommandStore.isSupported("stop-terminal")) + assertFalse(DebugCommandStore.isSupported("run-shell")) + assertFalse(DebugCommandStore.isSupported("")) + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/catalog/DistroCatalogParserTest.kt b/app/src/test/java/org/randomcoder/udroid/catalog/DistroCatalogParserTest.kt index 08f339d..9fab4e9 100644 --- a/app/src/test/java/org/randomcoder/udroid/catalog/DistroCatalogParserTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/catalog/DistroCatalogParserTest.kt @@ -13,6 +13,7 @@ class DistroCatalogParserTest { { "suites": ["jammy"], "jammy": { + "distribution": "ubuntu", "varients": ["raw"], "raw": { "Name": "udroid-jammy-raw", @@ -32,6 +33,41 @@ class DistroCatalogParserTest { assertTrue(catalog.variants.single().recommended) } + @Test + fun `catalogue uses declared distribution and does not guess missing or unknown ids`() { + val catalog = + DistroCatalogParser.parse( + """ + { + "suites": ["trixie", "future", "legacy"], + "trixie": { + "distribution": "debian", + "varients": ["plasma"], + "plasma": {"aarch64url": "https://example.test/trixie.tar.gz"} + }, + "future": { + "distribution": "other", + "varients": ["raw"], + "raw": {"aarch64url": "https://example.test/future.tar.gz"} + }, + "legacy": { + "varients": ["raw"], + "raw": {"aarch64url": "https://example.test/legacy.tar.gz"} + } + } + """.trimIndent(), + architecture = "aarch64", + ) + + val trixie = catalog.variants.first { it.suite == "trixie" } + assertEquals(LinuxDistribution.DEBIAN, trixie.distribution) + assertEquals("Debian Trixie", trixie.releaseName) + assertEquals("debian:trixie:plasma", trixie.id) + assertTrue(catalog.variants.filter { it.suite != "trixie" }.all { + it.distribution == LinuxDistribution.UNKNOWN && it.releaseName == "Unknown Linux" + }) + } + @Test fun `pinned proot distro catalogue exposes core distributions for arm64`() { val variants = ProotDistroArchiveCatalog.forArchitecture("aarch64") diff --git a/app/src/test/java/org/randomcoder/udroid/install/InstallProgressTest.kt b/app/src/test/java/org/randomcoder/udroid/install/InstallProgressTest.kt index b58b4e6..c40db42 100644 --- a/app/src/test/java/org/randomcoder/udroid/install/InstallProgressTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/install/InstallProgressTest.kt @@ -4,6 +4,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.randomcoder.udroid.catalog.DistroVariant +import org.randomcoder.udroid.catalog.LinuxDistribution class InstallProgressTest { private val distro = @@ -15,6 +16,7 @@ class InstallProgressTest { architecture = "aarch64", downloadUrl = "https://example.test/jammy.tar.gz", sha256 = "abc", + distribution = LinuxDistribution.UBUNTU, ) private val work = InstallerWorkRequest.Archive( diff --git a/app/src/test/java/org/randomcoder/udroid/install/InstallerWorkRequestTest.kt b/app/src/test/java/org/randomcoder/udroid/install/InstallerWorkRequestTest.kt index a9808ea..70ad87c 100644 --- a/app/src/test/java/org/randomcoder/udroid/install/InstallerWorkRequestTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/install/InstallerWorkRequestTest.kt @@ -50,6 +50,7 @@ class InstallerWorkRequestTest { architecture = "aarch64", downloadUrl = "https://example.test/ubuntu.tar.xz", sha256 = "b".repeat(64), + distribution = LinuxDistribution.UBUNTU, ) val expected = InstallerWorkRequest.Archive( diff --git a/app/src/test/java/org/randomcoder/udroid/install/ResetInstallationSelectionTest.kt b/app/src/test/java/org/randomcoder/udroid/install/ResetInstallationSelectionTest.kt index 690c5a3..c73cc2c 100644 --- a/app/src/test/java/org/randomcoder/udroid/install/ResetInstallationSelectionTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/install/ResetInstallationSelectionTest.kt @@ -5,6 +5,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.randomcoder.udroid.catalog.DistroVariant +import org.randomcoder.udroid.catalog.LinuxDistribution import org.randomcoder.udroid.oci.OciImageReference import org.randomcoder.udroid.oci.OciPlatform @@ -20,6 +21,7 @@ class ResetInstallationSelectionTest { architecture = "aarch64", downloadUrl = "https://example.invalid/debian.tar.xz", sha256 = "a".repeat(64), + distribution = LinuxDistribution.DEBIAN, ) val progress = ResetInstallationSelection.initial( diff --git a/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt b/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt index 334b245..8382c6c 100644 --- a/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt @@ -1,6 +1,8 @@ package org.randomcoder.udroid.ui import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class DesktopInputPaletteTest { @@ -10,4 +12,27 @@ class DesktopInputPaletteTest { assertEquals(3, nextLatchedMouseButton(1, 3)) assertEquals(0, nextLatchedMouseButton(3, 3)) } + + @Test + fun onlyAnUnchordedModifierTapCanEnterDoubleTapSequence() { + assertTrue(isQuickModifierTap(true, true, false, 50, 500)) + assertFalse(isQuickModifierTap(true, true, true, 50, 500)) + assertFalse(isQuickModifierTap(false, true, false, 50, 500)) + assertFalse(isQuickModifierTap(true, false, false, 50, 500)) + assertFalse(isQuickModifierTap(true, true, false, 1_000, 500)) + } + + @Test + fun modifierDoubleTapLocksOnlyConsecutiveQuickTapsOnSameButton() { + val taps = ModifierDoubleTap(300) + assertFalse(taps.tapped(0, 1_000)) + assertTrue(taps.tapped(0, 1_250)) + assertFalse(taps.tapped(0, 1_350)) + taps.cancelUnless(1) + assertFalse(taps.tapped(0, 1_500)) + assertFalse(taps.tapped(0, 2_000)) + assertTrue(taps.tapped(0, 2_300)) + taps.clear() + assertFalse(taps.tapped(0, 2_400)) + } } 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 3e8b3d9..33c3aaf 100644 --- a/app/src/test/java/org/randomcoder/udroid/ui/SupervisorReportTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/ui/SupervisorReportTest.kt @@ -44,4 +44,21 @@ class SupervisorReportTest { formatDiagnosticLogEntry(entry), ) } + + @Test + fun latestZinkCheckBecomesReadableCompatibilityRows() { + val results = + zinkCompatibilityResults( + listOf( + """{"timestamp":"2026-10-03T09:00:02Z","component":"graphics","severity":"info","event":"zink_profile","message":"OpenGL 4.6 baseline: unsupported","fields":{"profile":"VP_ZINK_gl46_baseline"}}""", + """{"timestamp":"2026-10-03T09:00:01Z","component":"graphics","severity":"debug","event":"zink_profile","message":"OpenGL 2.1 baseline: supported","fields":{"profile":"VP_ZINK_gl21_baseline"}}""", + """{"timestamp":"2026-10-03T09:00:00Z","component":"graphics","severity":"info","event":"zink_requirements","message":"Highest supported Zink profile: OpenGL 2.1 baseline","fields":{"rootfs":"proot-ubuntu-jammy"}}""", + ), + ) + + assertEquals("Zink through Venus", results[0].name) + assertEquals("OpenGL 2.1 baseline", results[1].name) + assertEquals("OpenGL 4.6 baseline", results[2].name) + assertEquals("VP_ZINK_gl46_baseline · Linux system: proot-ubuntu-jammy", results[2].detail) + } } diff --git a/app/src/test/java/org/randomcoder/udroid/virgl/VirglHostLaunchTest.kt b/app/src/test/java/org/randomcoder/udroid/virgl/VirglHostLaunchTest.kt index 8970cc8..218e63c 100644 --- a/app/src/test/java/org/randomcoder/udroid/virgl/VirglHostLaunchTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/virgl/VirglHostLaunchTest.kt @@ -166,7 +166,7 @@ class VirglHostLaunchTest { } @Test - fun `selects Venus vtest with the X11 copy presentation path`() { + fun `selects Venus vtest with native WSI presentation`() { val socket = Files.createTempFile("udroid-venus", ".sock").toFile() val runtime = Files.createTempDirectory("udroid-venus-runtime").toFile() val profile = @@ -197,8 +197,9 @@ class VirglHostLaunchTest { "GALLIUM_DRIVER", "-u", "MESA_LOADER_DRIVER_OVERRIDE", + "-u", + "MESA_VK_WSI_DEBUG", "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", @@ -227,4 +228,29 @@ class VirglHostLaunchTest { osRelease.writeText("ID=ubuntu\nVERSION_ID=\"20.04\"\n") assertFalse(VenusGuestRuntimeInstaller.requiresBundledMesa(rootfs)) } + + @Test + fun `parses verbose Zink profile results`() { + val results = + parseZinkRequirements( + """ + Checking profile VP_ZINK_gl21_baseline + Checking device Virtio-GPU Venus + Supported + + Checking profile VP_ZINK_gl46_baseline + Checking device Virtio-GPU Venus + UNSUPPORTED physical device + """.trimIndent(), + ) + + assertEquals( + listOf( + ZinkRequirementResult("VP_ZINK_gl21_baseline", true), + ZinkRequirementResult("VP_ZINK_gl46_baseline", false), + ), + results, + ) + assertEquals("OpenGL 2.1 baseline", results.first().label) + } } diff --git a/app/src/test/java/org/randomcoder/udroid/x11/MouseButtonStateTest.java b/app/src/test/java/org/randomcoder/udroid/x11/MouseButtonStateTest.java new file mode 100644 index 0000000..ec2cb43 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/x11/MouseButtonStateTest.java @@ -0,0 +1,43 @@ +package org.randomcoder.udroid.x11; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.randomcoder.udroid.x11.MouseButtonState.Source; + +public class MouseButtonStateTest { + @Test + public void sameButtonAcrossPalettesReleasesOnlyAfterLastOwner() { + MouseButtonState state = new MouseButtonState(); + assertEquals(Boolean.TRUE, state.set(Source.PALETTE, 1, true)); + assertNull(state.set(Source.OVERLAY, 1, true)); + assertNull(state.set(Source.PALETTE, 1, false)); + assertTrue(state.hasExternalButton()); + assertEquals(Boolean.FALSE, state.set(Source.OVERLAY, 1, false)); + assertFalse(state.hasExternalButton()); + } + + @Test + public void directTouchAndOverlayCanShareLeftButton() { + MouseButtonState state = new MouseButtonState(); + assertEquals(Boolean.TRUE, state.set(Source.DIRECT, 1, true)); + assertNull(state.set(Source.OVERLAY, 1, true)); + assertNull(state.set(Source.DIRECT, 1, false)); + assertEquals(Boolean.FALSE, state.set(Source.OVERLAY, 1, false)); + } + + @Test + public void releaseAllClearsEverySource() { + MouseButtonState state = new MouseButtonState(); + state.set(Source.PHYSICAL, 1, true); + state.set(Source.OVERLAY, 3, true); + assertEquals(1, state.pressedBy(Source.PHYSICAL).size()); + state.clear(); + assertFalse(state.isDown(1)); + assertFalse(state.isDown(3)); + assertFalse(state.hasExternalButton()); + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/x11/OverlayKeyboardTest.kt b/app/src/test/java/org/randomcoder/udroid/x11/OverlayKeyboardTest.kt new file mode 100644 index 0000000..bccba5f --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/x11/OverlayKeyboardTest.kt @@ -0,0 +1,81 @@ +package org.randomcoder.udroid.x11 + +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OverlayKeyboardTest { + @Test + fun bindingsRejectUnsupportedOrMissingKeys() { + val saved = listOf(KeyEvent.KEYCODE_F5, OverlayKeyboard.MOUSE_LEFT, -100) + val normalized = OverlayKeyboard.normalized(saved) + assertEquals(OverlayKeyboard.defaults.size, normalized.size) + assertEquals(KeyEvent.KEYCODE_F5, normalized[0]) + assertEquals(OverlayKeyboard.MOUSE_LEFT, normalized[1]) + assertEquals(OverlayKeyboard.defaults[2], normalized[2]) + } + + @Test + fun displayButtonsKeepValidKeysAndClampTheirPositions() { + val buttons = OverlayKeyboard.normalizedDisplayKeys( + listOf( + DisplayKey(KeyEvent.KEYCODE_W, -1f, 2f), + DisplayKey(-100, 0.5f, 0.5f), + DisplayKey(KeyEvent.KEYCODE_A, Float.NaN, 0.5f), + ), + ) + assertEquals(listOf(DisplayKey(KeyEvent.KEYCODE_W, 0f, 1f)), buttons) + } + + @Test + fun modifierClassificationDoesNotIncludeOrdinaryKeys() { + assertTrue(OverlayKeyboard.isModifier(KeyEvent.KEYCODE_CTRL_LEFT)) + assertTrue(OverlayKeyboard.isModifier(KeyEvent.KEYCODE_META_RIGHT)) + assertFalse(OverlayKeyboard.isModifier(KeyEvent.KEYCODE_W)) + } + + @Test + fun duplicateBindingsReleaseOnlyAfterLastOwner() { + val events = mutableListOf>() + val keys = OverlayKeyState { key, down -> events += key to down } + keys.press(1, KeyEvent.KEYCODE_W) + keys.press(2, KeyEvent.KEYCODE_W) + keys.release(1) + assertEquals(listOf(KeyEvent.KEYCODE_W to true), events) + keys.release(2) + assertEquals( + listOf(KeyEvent.KEYCODE_W to true, KeyEvent.KEYCODE_W to false), + events, + ) + } + + @Test + fun tappingModifierCanLatchWithoutAReleaseBetweenOwners() { + val events = mutableListOf>() + val keys = OverlayKeyState { key, down -> events += key to down } + keys.press(0, KeyEvent.KEYCODE_CTRL_LEFT) + keys.press(1, KeyEvent.KEYCODE_CTRL_LEFT) + keys.release(0) + assertEquals(listOf(KeyEvent.KEYCODE_CTRL_LEFT to true), events) + keys.release(1) + assertEquals( + listOf(KeyEvent.KEYCODE_CTRL_LEFT to true, KeyEvent.KEYCODE_CTRL_LEFT to false), + events, + ) + } + + @Test + fun releaseAllEndsHeldChordsExactlyOnce() { + val events = mutableListOf>() + val keys = OverlayKeyState { key, down -> events += key to down } + keys.press(0, KeyEvent.KEYCODE_CTRL_LEFT) + keys.press(1, KeyEvent.KEYCODE_W) + keys.releaseAll() + keys.releaseAll() + assertEquals(4, events.size) + assertEquals(1, events.count { it == (KeyEvent.KEYCODE_CTRL_LEFT to false) }) + assertEquals(1, events.count { it == (KeyEvent.KEYCODE_W to false) }) + } +} diff --git a/docs/DESKTOP_INPUT_AND_SESSION_ROADMAP.md b/docs/DESKTOP_INPUT_AND_SESSION_ROADMAP.md index 35a44f3..b292e08 100644 --- a/docs/DESKTOP_INPUT_AND_SESSION_ROADMAP.md +++ b/docs/DESKTOP_INPUT_AND_SESSION_ROADMAP.md @@ -20,6 +20,21 @@ implementation. ## Current input path +The app-owned on-screen keyboard sends Android keycodes through Lorie's existing +key transport, which maps them to Linux/X server keycodes. Unlike a text macro, +each touch has a separate key-down and key-up; the server's XKB state therefore +sees held modifiers and simultaneous keys. The overlay shares touch dispatch +with the display through Android `ViewGroup` motion-event splitting. It releases +all of its keys and mouse buttons on cancellation, hide, focus loss, renderer +detach, or surface loss. Bindings are restricted to keycodes present in Lorie's +mapping plus mouse buttons and wheel steps; gamepad axis output is not part of +this X11 input contract. + +References: [XKB keyboard state](https://www.x.org/archive/X11R7.7/doc/libX11/XKB/xkblib.html), +[Android MotionEvent cancellation](https://developer.android.com/reference/android/view/MotionEvent), +[ViewGroup split motion events](https://developer.android.com/reference/android/view/ViewGroup#setMotionEventSplittingEnabled(boolean)), +and [Android KeyEvent](https://developer.android.com/reference/android/view/KeyEvent). + ```mermaid flowchart LR Android["Android MotionEvent"] --> View["uDroid X11DisplayView"] @@ -539,3 +554,58 @@ not prohibited by the architecture. Normal UI may call these Linux workspaces. Advanced UI may expose display numbers, PIDs, sockets, leases, and renderer state. + +## Native Wayland session (planned, opt-in) + +Wayland is a separate session backend, not Wayland-over-Lorie. Keep the existing +Lorie/X11 session unchanged as the fallback. The Android app owns the Wayland +compositor and its Android surface; Linux clients run in the selected PRoot and +connect through a private Wayland socket. A guest desktop compositor such as +KWin is nested as a Wayland client, so this is rootless and does not pretend +that PRoot owns DRM/KMS. + +Use an Android-hosted wlroots compositor as the reference architecture. +WLDroid at `9560e767bb3e31f3d9b72d280e051c47cda35cf1` is a useful black-box +reference, not a safe integration dependency yet. Its current code advertises +fabricated DRM device feedback, assumes a private AHardwareBuffer native-handle +layout for DMA-BUF export, submits buffers without acquire fences, and drives +frames from a fixed 16 ms timer. Do not carry these assumptions into uDroid. +Audit every dependency license before considering any reuse. Do not port the +GPL-3.0 wlroots-android-bridge code into uDroid. The existing experimental +AHardwareBuffer/SurfaceControl presenter is a candidate output component, not +proof that the complete Wayland protocol, client-buffer import, or KWin path is +ready. + +### First checkpoint and current status + +The pinned source audit is complete, but its reference build is not. The local +macOS build reached the cross-build and stopped because the host Bison is 2.3, +below libxkbcommon's required 3.6. Meson was installed only in a temporary +Python environment; no APK or compositor library was produced, and nothing +was installed on a device. + +Do not run stock WLDroid with KWin: it advertises fabricated DRM device +feedback and its AHardwareBuffer output omits acquire fences. A standalone APK +also cannot connect to uDroid's app-private rootfs/socket because it has a +different Android UID. These are architectural blockers, not just build +friction. + +1. Keep the current X11 path unchanged. Do not import WLDroid's Proot, launcher, + VirGL, or Android shims into uDroid. +2. If continuing with this reference, first make a disposable development + variant that completely disables its linux-dmabuf global/feedback and uses + a software/SHM client-buffer path plus CPU-copy presentation. Do not expose + fake DRM identity or the current no-fence AHardwareBuffer path. +3. Embed that compositor in a uDroid dev build so the selected distro connects + through a same-UID private Wayland socket; a separate APK cannot test this + boundary. +4. Launch `weston-simple-shm`; verify pixels, resize, touch/pointer/keyboard + input, detach/reattach, and clean stop/restart before trying EGL or KWin. +5. Add EGL and guest KWin only after the SHM/lifecycle gate passes. Keep + `linux-dmabuf` disabled until device feedback is truthful and the complete + buffer import plus acquire/release ordering is validated. + +The first checkpoint passes only if X11 remains unaffected, the SHM client is +visibly correct, input reaches it, and the session survives lifecycle cycles. +GPU acceleration, Plasma support, and performance qualification are later +gates; the Wayland option stays hidden/disabled until each is demonstrated. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index ebf55a9..18ba40a 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -62,6 +62,9 @@ Keep expensive runtime scenarios small and reproducible first. Add focused Macrobenchmarks or microbenchmarks for a suspected cost, inspect traces, then validate the resulting change in the complete user journey. +For Venus, Zink, Lorie, and SurfaceFlinger profiling, use the reproducible +[Venus Perfetto capture](graphics/VENUS_PERFETTO_PROFILING.md). + References: - [Compose performance](https://developer.android.com/develop/ui/compose/performance) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 6bc82a8..d991677 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -74,18 +74,60 @@ shortcut always points to that application in that specific Linux system. ## Control the desktop -The desktop toolbar opens the keyboard, mouse controls, and display settings. - -- **Direct** touch moves the pointer to your finger. -- **Trackpad** touch moves the pointer relative to your finger and is useful - for precise desktop work. -- **Native** touch sends multiple touch points directly to Linux applications - that support them. -- The mouse palette provides left, middle, right, and scroll controls. Hold a - button while moving another finger on the display to drag. - -Display settings can change resolution, scaling, sharpness, pointer speed, -keyboard handling, and whether the screen stays awake. +The top toolbar opens the Android keyboard, mouse palette, display buttons +(**Keys**), and desktop settings. Its arrow collapses the toolbar; tap the +compact handle to bring it back. + +### Pointer and mouse + +Choose a touch mode in **Desktop settings → Pointer**: + +- **Direct:** the pointer follows your finger. +- **Trackpad:** move the pointer relative to your finger for finer control. +- **Native:** send touch points to Linux apps that support touch input. + +The mouse palette has **L**, **M**, **R**, and scroll up/down. Tap a mouse +button to keep it pressed; tap it again to release it. Hold a button and move +another finger on the desktop for a temporary press-and-drag. Drag the +palette by its **Mouse** header, or tap its **Lock** control to keep the +palette in place. That Lock controls the palette's position, not a mouse +button. + +### Keyboard strip and display buttons + +The two-row keyboard strip stays at the bottom even when the Android keyboard +is closed. If **Keep desktop above keyboard** is on, the strip moves above +the Android keyboard when it opens. Turn **Show keyboard strip** off in +**Desktop settings → Keyboard** to reclaim the space. + +The strip has 12 editable slots. In **Desktop settings → Keyboard**, tap a +slot to assign a letter, number, modifier, arrow, function or navigation key, +supported punctuation key, mouse button, or scroll direction. **Reset keyboard +strip** restores the default bindings. + +**Keys** on the toolbar shows separate translucent buttons over the Linux +display. Add, change, or remove them in **Desktop settings → Keyboard**. To +move one, choose **Arrange on display**, drag it to the desired place, then +tap **Done** on the toolbar. Positions are saved. Normal use does not move +buttons, so holding a key cannot accidentally drag it. + +Both the strip and display buttons use the same key gestures: + +| Gesture | Result | +| --- | --- | +| Tap a key | Press and release it once. | +| Hold a key | Keep it pressed until your finger lifts; hold several for chords or WASD diagonals. | +| Double-tap a modifier | Lock Ctrl, Alt, Shift, or Super on. Tap it once to unlock. | + +For example, hold **Ctrl** with one finger and tap **C** with another. A +single tap on Ctrl alone sends a complete press-and-release. Hiding controls, +changing their bindings, leaving the display, or losing the renderer releases +held and locked inputs. + +Desktop settings also control resolution, scaling, sharpness, pointer speed, +keyboard handling, and whether the screen stays awake. **Keep desktop above +keyboard** resizes Linux into the space above the Android keyboard; turn it +off if you prefer the keyboard to cover the desktop without resizing it. ## Audio and microphone diff --git a/docs/graphics/GNOME_VENUS_CHECKPOINT_2026-10-04.md b/docs/graphics/GNOME_VENUS_CHECKPOINT_2026-10-04.md new file mode 100644 index 0000000..270afd3 --- /dev/null +++ b/docs/graphics/GNOME_VENUS_CHECKPOINT_2026-10-04.md @@ -0,0 +1,123 @@ +# GNOME / Venus checkpoint — 2026-10-04 + +Originally paused at the user's request. The source patches were subsequently pushed. This is a historical investigation record, not a current device-state report or GNOME qualification. A controlled EGL/Gallium A/B was run on 2026-10-09; the GNOME issue was not resolved. + +## Locations + +- App: `/Users/saicharankandukuri/anyc/udroid-android-ci`, branch `improvements/zink-requirements-check`. +- Mesa: `/Users/saicharankandukuri/anyc/worktrees/mesa-venus-vtest-guard`, base `6a02618ccf6c5651ecb9cccbde571eb61fd73592` (26.1.5). +- Pixel 6a: ADB serial `36171JEGR11359`; app `org.randomcoder.udroid.dev`; rootfs `noble-raw`. +- Session: `GNOME on uDroid (Venus WSI)`. + +## Patch inventory + +Mesa has four source changes, pushed as `7c8f8371df1` on `fix/venus-zink-gnome-compat`: + +- `src/virtio/vulkan/vn_renderer.h`: guard an unavailable `create_from_dma_buf` callback. +- `src/gallium/drivers/zink/zink_screen.c`: derive DMA-BUF capabilities from Vulkan external-buffer properties. +- `src/gallium/frontends/dri/kopper.c`: use imported pixmap buffers only when DMA-BUF import is supported. +- `src/gallium/auxiliary/pipe-loader/pipe_loader_sw.c`: expose Zink's DRI configuration table when the swrast loader selects `GALLIUM_DRIVER=zink`. This fixes the missing `dual_color_blend_by_location` option assertion in `mutter-x11-frames`. + +App changes were pushed as `3774493` on `improvements/zink-requirements-check`. They include the Mesa assets/manifest, X11 service diagnostics, performance/roadmap docs, profiling tools, and Termux:X11 patches: + +- `0008`: buffer transport protocol additions. +- `0011`: Perfetto slices. +- `0012`: raw FD import diagnostics. +- `0013`: Present flip height comparison fix; inspect applicability before using direct flip. +- `0014`: Present lifecycle diagnostics. +- `0015`: DRI3 pixmap export. The patch's hunk counts were corrected and it parses, but normal source preparation still needs a separate build validation; the A/B APKs used `-x prepareTermuxX11Source`. +- `0016`: accept existing FD and AHardwareBuffer pixmaps as GPU-copy sources. Wired into experimental profile application/removal in `tools/prepare-termux-x11-source.sh`. + +The active native source also contains the `0016` change in `third_party/termux-x11/lorie/src/main/cpp/lorie/InitOutput.c`. Preserve unrelated app changes. + +## Evidence and remaining failure + +- Patched Mesa builds and the dev APK build/install succeeded. The committed Jammy assets require at most GLIBC 2.34. The earlier Debian/GCC14 proof Gallium needs GLIBC 2.38 (`__isoc23_*` symbols) and **must not be shipped** as the Jammy-compatible asset. +- The missing DRI option crash is fixed, but GNOME interaction remains unqualified: user observed sudden corruption and a crash. +- One captured failure showed GNOME and `mutter-x11-frames` still alive, followed by an Android back gesture detaching the display. Do not generalize that observation to every reported crash. +- Before `0016`, Lorie rejected imported FD sources (`type=2`) and reported zero GPU-offloaded Present copies. +- After `0016`, a clean desktop rendered and logs reported `10/10 present copies offloaded to GPU`. This proves the transport path was used, not sustained GNOME correctness. +- Subsequent UXTerm and Thunar launches reached X11 but remained `IsUnMapped`; Mutter's `_NET_CLIENT_LIST` was empty while GNOME and its frame process remained alive in poll. +- SurfaceFlinger emitted `writeReleaseFence failed ... Broken pipe` around surface-generation changes and app launches. Causality is unresolved. +- Forced direct flip was rejected as an experiment: root CPU-lock/unflip paths are unsafe for GPU-only buffers and could reproduce the pinned-pixmap failure. +- Left-edge ADB taps triggered predictive back. Use a central input target or X11 input tools for future GNOME interaction tests. +- Both repository diffs passed `git diff --check` at checkpoint. + +## 2026-10-09 controlled A/B on Pixel 6a + +All runs used the same Noble rootfs, Virtio Vulkan asset (`9a21b21f90484960b3a3383144e7bd65bb725ede546ae4ce205f978b894f54a1`), app runtime, and Lorie `0016`. The EGL/Gallium pair was the only APK asset variable. Each APK was installed and GNOME restarted. The original Jammy assets were backed up under `/tmp/udroid-gnome-ab-current-20261009`. + +| EGL | Gallium | Result | +| --- | --- | --- | +| Jammy `01360784` | Jammy `00de405a` | Corrupt/duplicated output; UXTerm `IsUnMapped`, `_NET_CLIENT_LIST` empty. | +| Debian proof `5e1fc256` | Debian proof `fab409d3` | Clean desktop; UXTerm and Thunar both `IsViewable` and listed by Mutter. | +| Debian proof `5e1fc256` | Jammy `00de405a` | Corrupt output; UXTerm `IsUnMapped`. | +| Jammy `01360784` | Debian proof `fab409d3` | UXTerm `IsViewable` and visibly rendered, but a later Thunar launch stayed `IsUnMapped`. This is only a partial improvement. | + +The Jammy Gallium binary/build is implicated by both UXTerm splits, but the mixed pair does not qualify GNOME. The proven EGL/Gallium pair's glibc floor also prevents shipping it across supported Ubuntu versions. Do not assume a single driver-source regression: the binaries were compiled with different toolchains and potentially different Meson settings. The proof build used Debian GCC 14.2, `-Dbuildtype=debugoptimized`, and `-Dgbm=enabled`; the Jammy binary reports Ubuntu GCC 11.4, but its original build directory/configuration still needs inspection. Docker Desktop failed to start on 2026-10-09, so this comparison could not be completed. + +Evidence: `/tmp/udroid-ab-uxterm.png`, `/tmp/udroid-ab-thunar.png`, `/tmp/udroid-mixed2-uxterm.png`, `/tmp/udroid-gnome-current-20261009.png`, and `/tmp/udroid-gnome-mixed-thunar-20261009.png`. These screenshots are temporary and should not be treated as durable artifacts. A visible UXTerm alone is not a GNOME stability result. + +The user then dragged UXTerm on the mixed Jammy-EGL/proof-Gallium build and reported the display dead. The Android screenshot stayed on the prior frame. At the same time, `gnome-shell`, `mutter-x11-frames`, Xterm, Thunar, and both app processes remained alive; X11 reported UXTerm `IsViewable`, Thunar `IsUnMapped`, and `_NET_CLIENT_LIST` contained UXTerm only. Injecting `FRAME_PROBE_20261009` into Xterm's PTY changed an `xwd` capture of the UXTerm window (`07e51112…` to `411821f4…`), but neither `xwd -root` nor the Android screenshot showed the new text. The immediate stall is therefore after the client window's pixel update and before the composed X11 root is refreshed; the Android surface alone cannot explain it. SurfaceFlinger emitted repeated `writeReleaseFence failed. error 32 (Broken pipe)` around 15:28:30 local time, but causality is not established. The main GNOME thread was in `do_sys_poll`, not an observed synchronous GPU wait. Evidence: `/tmp/udroid-freeze-window-before.png`, `/tmp/udroid-freeze-window-after.png`, `/tmp/udroid-freeze-root-after.png`, and `/tmp/udroid-freeze-screen-after-probe.png`. + +At that checkpoint, the local app repository's Gallium asset and manifest were restored to their committed Jammy hashes. **The installed dev APK on the Pixel still contained the mixed diagnostic pair at that time**; its current installed state must be checked afresh. Do not mistake that diagnostic pair for a compatible release build. + +The first software-rendering A/B was **inconclusive**: the generic `GNOME on Xorg` session did not remain running and logged service/D-Bus failures, while `/usr/local/bin/udroid-gnome-venus-wsi` explicitly sets `GALLIUM_DRIVER=zink` and Venus library paths regardless of the app's graphics selector. These were not equivalent sessions. + +A fairer control then reused the uDroid GNOME compatibility launcher, intercepting only its final `gnome-shell` invocation with a temporary executable on `PATH`. That executable removed the Venus/Zink library overrides and set `LIBGL_ALWAYS_SOFTWARE=1`. `/proc/3868/environ` confirmed the software flag and no Venus/Zink overrides; `/proc/3868/maps` showed Noble's own `libEGL_mesa.so`, `libGLX_mesa.so`, and `libgallium-25.2.8-0ubuntu0.24.04.4.so`. The app displayed GNOME and a persistent UXTerm, including `FRAME_PROBE_20261009` text that was absent from the frozen accelerated root. The user moved UXTerm and reported no issue with software rendering. This is one bounded device run, not a sustained stability qualification, but it narrows the failure toward the accelerated compositor path rather than the generic GNOME compatibility startup or Android display surface alone. + +The software control desktop was stopped. Its temporary guest executable and `.desktop` entry were removed and verified absent; the app's original Venus graphics selection and `GNOME on uDroid (Venus WSI)` session selection were restored. The desktop was left stopped; the Linux rootfs/terminal remained installed and running. The UI may briefly cache the removed session entry until its next scan. + +## Matched Jammy rebuild awaiting device A/B + +Docker Desktop was recovered. The preserved `udroid-mesa232-build` Ubuntu 22.04 container uses GCC 11.4 and Meson 1.7.2, but its bind mount points to a partial `/tmp/mesa-23.2.1` tree, **not** the original Mesa 26.1.5 asset build directory. It cannot establish the committed Jammy binary's original Meson settings. A clean archive of Mesa revision `7c8f8371df1` was copied into the container at `/work/mesa261` and built with the proof build's explicit options: `platforms=x11`, `gallium-drivers=zink`, no Vulkan drivers, `glx=dri`, EGL and GBM enabled, LLVM and video codecs disabled, and `buildtype=debugoptimized`. Both target libraries built successfully. After `strip --strip-unneeded`, their hashes are: + +| Diagnostic library | SHA-256 | Maximum required glibc | +| --- | --- | --- | +| `libEGL_mesa.so.0` | `5d09a3e23759234728f89662758ec006f39fd93ce091f2d2affa7e80d5dc1235` | 2.34 | +| `libgallium-26.1.5.so` | `56f95695a36df5f9cecdddb9481adf7697aedad1fff800775bd7bb3b72d1a025` | 2.34 | + +The binaries are in `/tmp/udroid-gnome-jammy-matched-20261009` on the Mac and remain in the preserved container. `:app:assembleDev -x prepareTermuxX11Source --offline` succeeded, including `verifyGraphicsRuntimeAssets`. The resulting `app/build/outputs/apk/dev/app-dev.apk` contains the diagnostic hashes, verified from the packaged binary bytes; a copy is at `/tmp/udroid-gnome-jammy-matched-20261009/app-dev.apk`. The Pixel disconnected immediately before `adb install`, so **this APK has not been installed or runtime-tested**. The app source assets and manifest were restored to their committed Jammy hashes and `git status` shows only this untracked checkpoint; a later Gradle build will replace the original APK output, and `/tmp` copies are not durable. Both temporarily started Docker containers were stopped without deleting them. + +## Binary identity + +Committed Jammy app assets: + +| Library | SHA-256 | +| --- | --- | +| EGL | `013607840e63dc6cbca87e3ca3126f46734ec102b2072b5757d509615a3a1a46` | +| Gallium | `00de405a69fab61c49f0dbbe6a13c0b8332ffb664f20ed021baba0b713d2f06c` | +| Virtio Vulkan | `9a21b21f90484960b3a3383144e7bd65bb725ede546ae4ce205f978b894f54a1` | + +Previously proven Debian EGL/Gallium pair located and hash-verified (diagnostic only, not release-compatible with GLIBC 2.34): + +- `build-zink-vtest-fallback/src/egl/libEGL_mesa.so.0.0.0`: `5e1fc256dadcf31595a3bcbdbc216e154071054e82a3d933c0daa9f1b24ad041`. +- `build-zink-vtest-fallback/src/gallium/targets/dri/libgallium-26.1.5.so`: `fab409d33b287ca84ac39a1e0109932ba3851923797c2d297de29f1267dab887`. + +The current `build-wsi-virtio` binary hashes to `b49048234bb4717a34f4e8eaebb46221407aaf54c8ab875f3e8fff017a0efc59`; it does not match the recorded prior proof Virtio hash `6d1ead4806ad513e4c3aaee2f0e3419e577e454c0356a1beb25003eeaf2c7b6d`. + +`/tmp/udroid-mesa-original-assets-20261004` is an older backup, not the exact proof pair. Do not treat it as proven. Temporary files may disappear; the Mesa build tree holds the located proof pair. + +## Resume here + +1. Reconnect the Pixel and install the existing diagnostic `app-dev.apk`; confirm the extracted EGL/Gallium hashes before launching GNOME. Do not mistake it for a release build. +2. A/B this matched pair with the same Virtio and Lorie runtime. Launch persistent UXTerm and Thunar clients, inspect `IsViewable` plus `_NET_CLIENT_LIST`, and compare client-window `xwd`, root `xwd`, and Android screenshots during several drags. If the client changes but root stalls, trace Mutter's damage/event dispatch before changing Lorie's Android presentation path. +3. Compare the accelerated drag test with the observed software control. One clean accelerated frame or one software run is insufficient to claim stability; continue with overview, repeated launches, and GPU-copy counters. +4. Find or reconstruct the committed Jammy asset's build settings before deciding whether the new pair replaces it. Validate normal source preparation (`0015`) separately before any release. + +Existing build container `udroid-mesa232-build` must be preserved. Check container ownership before cleanup. Screenshots previously saved under `/tmp/udroid-gpucopy-fd-*.png` are supporting evidence only and may be temporary. + +## 2026-10-18 GNOME Venus retest + +The generic `gnome` X session is still not a valid compatibility test. It reached +GNOME's recovery screen and logged missing runtime/system-bus setup together with +Venus/Zink swapchain initialization failures. The dedicated +`udroid-gnome-venus-wsi` launcher did render the overview, but opening Show Apps +left a black/frozen display while `gnome-shell` and `mutter-x11-frames` remained +alive. Lorie reported about `0.8 FPS`, acknowledged all four observed GPU-copy +presents, and SurfaceFlinger repeatedly logged release-fence `Broken pipe` errors. + +Temporary evidence: `/tmp/udroid-gnome-start.png`, +`/tmp/udroid-gnome-compat-appgrid.png`, +`/tmp/udroid-gnome-compat-supervisor.jsonl`, and +`/tmp/udroid-gnome-compat-logcat.txt`. diff --git a/docs/graphics/LORIE_DMABUF_GPU_IMPORT.md b/docs/graphics/LORIE_DMABUF_GPU_IMPORT.md new file mode 100644 index 0000000..3c88cf2 --- /dev/null +++ b/docs/graphics/LORIE_DMABUF_GPU_IMPORT.md @@ -0,0 +1,652 @@ +# Lorie DMA-BUF GPU import dossier + +Status: implementation gate, 2026-10-20 + +## Goal + +Replace Lorie's raw-FD presentation hot path: + +```text +Panfrost DMA-BUF -> mmap -> DMA_BUF_IOCTL_SYNC -> glTexSubImage2D -> GLES texture +``` + +with a GPU-native path: + +```text +Panfrost DMA-BUF -> EGLImage or Android-native buffer -> GLES texture -> Android Surface +``` + +The result is only correct when buffer metadata, producer completion, consumer +completion, and reuse are all handled. Importing the FD without that complete +contract can produce the stale, partially repainted, or reordered frames already +seen during window movement. + +This document is the implementation checklist. It does not make the raw path a +default or remove the synchronized CPU fallback. + +The evidence vocabulary, normative source conclusions, local code map, and the +allocation/lifecycle decision are centralized in +[`PRESENTATION_CONTRACT_REFERENCE.md`](PRESENTATION_CONTRACT_REFERENCE.md). + +## Current path and evidence + +G0 is implemented as a non-invasive startup probe in patch `0020`. Renderer +startup logs the active EGL vendor/version, DMA-BUF and AHardwareBuffer import +capabilities, synchronization extensions, eligible route, and fallback reason. +The probe does not change allocation, import, presentation, or fallback. + +The Pixel 6a gate reports `eligible_route=ahardwarebuffer`: direct app-facing +DMA-BUF import is unavailable, while the native-buffer, EGLImage, external +texture, native-fence, and wait-sync capabilities are exposed. + +### Producer + +The Tensor G1 Panfrost fork allocates shareable color buffers from an Android +DMA heap, imports them into kbase, and exports a duplicated DMA-BUF FD: + +The following paths are in the patched Mesa source tree used to build the guest +driver; that source tree is not vendored in this repository. + +- `src/panfrost/lib/kmod/kbase_kmod.c`: `DMA_HEAP_IOCTL_ALLOC`, kbase import, + and `kbase_kmod_bo_export_fd()`. +- `src/gallium/drivers/panfrost/pan_resource.c`: + `panfrost_resource_get_handle()` returns the FD, stride, offset, and modifier. +- `src/gallium/frontends/dri/loader_dri3_helper.c`: the Termux:X11 transport + sends one FD with private marker `1274` through DRI3 `PixmapFromBuffers`. + +`1274` is a transport discriminator, not a DRM format modifier. It must never +be passed to EGL as `EGL_DMA_BUF_PLANE0_MODIFIER_*_EXT`. + +### Consumer + +The embedded Lorie server receives the FD in +`third_party/termux-x11/lorie/src/main/cpp/lorie/InitOutput.c` and wraps it as +`LORIEBUFFER_FD`. Its current renderer does not import that DMA-BUF into the +Android GPU: + +1. `LorieBuffer_wrapFileDescriptor()` mmaps the FD. +2. `LorieBuffer_attachToGL()` allocates an ordinary `GL_TEXTURE_2D`. +3. Every `LorieBuffer_bindTexture()` brackets a full-frame + `glTexSubImage2D()` upload with `DMA_BUF_IOCTL_SYNC`. + +By contrast, the existing `LORIEBUFFER_AHARDWAREBUFFER` path creates an +`EGLImage`, binds it to a texture, and can sample it as +`GL_TEXTURE_EXTERNAL_OES`. + +### Pixel 6a capability snapshot + +Observed on the connected Pixel 6a on 2026-10-20: + +- Android META-EGL: EGL 1.5. +- App-facing EGL advertises `EGL_ANDROID_image_native_buffer`, + `EGL_ANDROID_native_fence_sync`, `EGL_KHR_wait_sync`, and + `EGL_KHR_image_base`. +- App-facing EGL does **not** advertise `EGL_EXT_image_dma_buf_import` or + `EGL_EXT_image_dma_buf_import_modifiers`. +- The vendor `/vendor/lib64/egl/libGLES_mali.so` contains the DMA-BUF import + extension names and `eglQueryDmaBufFormatsEXT` / `eglQueryDmaBufModifiersEXT` + symbols. + +This distinction is intentional Android architecture: META-EGL exposes an +allow-listed set of vendor extensions to applications. Header declarations and +symbols in the vendor library do not make an extension valid on the +app-facing `EGLDisplay`. + +Consequently, direct `EGL_LINUX_DMA_BUF_EXT` import is not a portable Android +app implementation and is not currently available through Lorie's existing +EGL display on this device. + +## Required buffer contract + +Every imported image needs all of the following metadata. An FD alone is not +an image description. + +| Field | Initial supported value | Rule | +| --- | --- | --- | +| Plane count | 1 | Reject or fall back for any other value. | +| Width, height | X pixmap dimensions | Both non-zero and bounded. | +| FourCC | Probe `DRM_FORMAT_XRGB8888` and `DRM_FORMAT_ARGB8888` | AHardwareBuffer format enums are not DRM FourCC values. | +| FD | duplicated DMA-BUF FD | Set `CLOEXEC`; define one owner and close point. | +| Offset | producer-provided byte offset | Validate against allocation size. | +| Pitch | producer-provided byte stride | Must be at least `width * 4`; do not substitute width. | +| Modifier | initially `DRM_FORMAT_MOD_LINEAR` only | `1274` is translated only after the producer proves a linear allocation. | +| Color/alpha semantics | XRGB or ARGB, established by probe | Never infer channel order from the numeric AHardwareBuffer enum. | + +The first implementation must stay single-plane and linear. Tiled, compressed, +AFBC, multi-plane, and YUV imports need real modifier negotiation and are out of +scope for the first gate. + +Validate `offset + pitch * (height - 1) + width * 4` with overflow-safe +arithmetic against the DMA-BUF size when the exporter exposes a meaningful +size. Reject malformed metadata before EGL, mmap, or GL sees it. + +## Import routes + +### Route A: app-facing EGL DMA-BUF import + +Use only when the active `EGLDisplay` advertises both: + +- `EGL_EXT_image_dma_buf_import` +- `EGL_KHR_image_base` + +Use `eglCreateImageKHR` with: + +```text +display = renderer's active EGLDisplay +context = EGL_NO_CONTEXT +target = EGL_LINUX_DMA_BUF_EXT +buffer = NULL +attributes = width, height, DRM FourCC, plane-0 FD, offset, pitch +``` + +If the modifier extension is advertised, query the FourCC/modifier pair first +with `eglQueryDmaBufFormatsEXT` and `eglQueryDmaBufModifiersEXT`. Supply both +the high and low modifier attributes or neither. Never treat an omitted or +`DRM_FORMAT_MOD_INVALID` modifier as equivalent to `DRM_FORMAT_MOD_LINEAR`; +the former is implementation-defined while the latter is an explicit linear +layout guarantee. + +On successful image creation EGL retains a reference to the DMA-BUF, but the +application still owns and must close the FD. Lorie should keep its owned FD +for the `LorieBuffer` lifetime for diagnostics and fallback, then close it once +during destruction. + +This route is unavailable on the current Pixel app-facing display and must +remain capability-gated. + +### Route B: Android-native AHardwareBuffer transport + +This is the portable Android import route. BufferQueue normally makes the +consumer own the queue and its slots, but that does not require Lorie to +allocate every buffer in a custom transport. The existing Panfrost route in +`SaicharanKandukuri/tensor-g1-proot-gpu` uses a Bionic broker to own the AHB, +gives its data DMA-BUF to glibc Panfrost, and gives its complete native handle +to Lorie through private DRI3 marker `1255`. + +That route already has a three-buffer ring. Mesa blocks for producer completion +before Present, retains each `pipe_resource`, and releases it only after X +Present `IdleNotify`. The next work is correlated tracing and abnormal teardown +validation, not another one-buffer transport. + +uDroid already has Lorie handle transport markers `1255`, `1256`, and `1257` +and the receiving/import code. The Tensor route does not yet carry the +gfxstream presenter's explicit cross-process fences and generation retirement, +so keep those protocols separate and add only behavior proven necessary by a +failing lifecycle control. + +Do not fabricate an AHardwareBuffer by placing an arbitrary DMA-heap FD in a +`native_handle_t`. `AHardwareBuffer_createFromHandle()` is a VNDK API that +registers a gralloc handle through `GraphicBuffer`; a plain DMA-BUF lacks the +vendor gralloc handle metadata that import may require. + +Android-native AHB import is the portable principle, but the current broker and +producer are Tensor/Panfrost-specific and not broad-device-qualified. +Allocation inversion is an A/B experiment, not a prerequisite. + +### Route C: direct vendor/SPHAL EGL + +The Pixel Mali blob appears to implement raw DMA-BUF EGL import even though +META-EGL does not expose it. A device-specific experiment may load the vendor +EGL implementation through the SPHAL namespace and create a fully vendor-owned +display, context, and import path. + +This route must not mix a META-EGL display/context handle with direct vendor +entry points. It also must not assume that vendor EGL ABI, linker namespace, +SELinux access, or extension exposure is consistent across devices. + +Minimum probe before any integration: + +1. Load the vendor implementation without replacing Lorie's active renderer. +2. Create and initialize an independent vendor EGL display and GLES context. +3. Confirm the extension strings on that exact display/context. +4. Query and import one known linear XRGB/ARGB DMA-BUF. +5. Render a checksum-known pattern to an independent target. +6. Destroy all objects repeatedly with no TLS corruption, FD leaks, or crash. + +Only after this isolated probe passes may the route be considered for an +experimental per-device backend. It is not the default implementation plan. + +### Route D: Vulkan consumer + +A Vulkan Lorie renderer could import external memory and composite to the +Android surface, but only if the active vendor Vulkan implementation exposes a +compatible external-memory FD, DRM modifier, and synchronization contract. +This is a renderer rewrite and is larger than either Route A or Route B. Keep +it as a fallback research route, not the first patch. + +## Texture target selection + +For Route A, call `eglQueryDmaBufModifiersEXT` when available: + +- `external_only == EGL_FALSE`: bind the EGLImage to `GL_TEXTURE_2D` and use + `sampler2D`. +- `external_only == EGL_TRUE`: require `GL_OES_EGL_image_external`, bind to + `GL_TEXTURE_EXTERNAL_OES`, and use `samplerExternalOES`. + +Lorie already has external-texture shaders for its AHardwareBuffer path; reuse +them. Cache one `EGLImageKHR` and texture view per `LorieBuffer`. Never create +or upload a texture per frame. + +Use clamp-to-edge and explicit min/mag filters. Preserve the existing BGRA/RGBA +swizzle decision, but base it on the negotiated FourCC and the colored-pattern +probe rather than the private transport marker. + +## Synchronization contract + +Correct synchronization has two independent directions. + +### Acquire: producer finished writing + +Lorie must not sample a buffer until Panfrost has completed all writes. + +Preferred order: + +1. An explicit producer `sync_file` FD transported with the frame. +2. Import it as `EGL_SYNC_NATIVE_FENCE_ANDROID` and issue + `eglWaitSyncKHR()` before the sampling draw. +3. Transfer FD ownership exactly as specified: successful + `eglCreateSyncKHR()` owns the supplied FD. + +An implicit-sync path is valid only after a probe proves that kbase/Panfrost +attaches the producer fence to the DMA-BUF reservation object and that the EGL +importer waits on it. The current custom kbase path has no demonstrated +`dma_resv` producer-fence attachment, so implicit synchronization must not be +assumed. + +For the first correctness proof, a bounded producer-side GPU completion wait +before Present is acceptable. It reduces parallelism but establishes that +format and lifetime are correct before adding an explicit fence transport. + +### Release: Lorie finished reading + +The producer must not render into or free a buffer while Lorie's GLES draw is +still sampling it. + +After queuing the last read of a source buffer: + +1. Create `EGL_SYNC_NATIVE_FENCE_ANDROID` with no imported FD. +2. Flush the GLES command stream. +3. Duplicate the native fence FD with `eglDupNativeFenceFDANDROID()`. +4. Send or attach that release fence to the producer-side reuse state. +5. Reuse the image only after that fence signals. + +Until release-fence transport exists, Lorie may block on its EGL fence before +signalling Present idle. That is a safe control, not the final performance +path. + +### What does not provide this contract + +- `DMA_BUF_IOCTL_SYNC` brackets CPU cache access. It does not exclude another + CPU or device user and does not by itself order two GPUs. +- `present_event_notify()` reports the Present flip event. Calling it + immediately after scheduling a redraw does not prove Lorie's GPU finished + reading the old pixmap. +- Present 1.2 `IdleNotify` and X Sync fences do not provide the same GPU-idle + guarantee as a native `sync_file`. + +The embedded X server currently advertises DRI3 1.2 and Present 1.2. Its bundled +protocol descriptions contain newer DRI3 syncobj and `PresentPixmapSynced` +requests, but those are not active server capabilities. DRM timeline syncobjs +also do not directly solve synchronization for a non-DRM kbase producer. + +## Lifetime and threading + +Per imported buffer, own: + +- one duplicated DMA-BUF FD or one acquired AHardwareBuffer reference; +- one `EGLImageKHR`; +- one `GL_TEXTURE_2D` or `GL_TEXTURE_EXTERNAL_OES` texture; +- pending acquire/release fence state; +- immutable dimensions, FourCC, pitch, offset, and modifier; +- a generation/serial so stale renderer messages cannot bind a recycled ID. + +Create and destroy GL textures on a thread with the owning context current. +Destroy the texture before the EGLImage, then release the native buffer/FD. +Surface detachment, Activity recreation, X server disconnect, pixmap +destruction, and import failure must converge on the same one-time teardown. + +Never release a `LorieBuffer` merely because Present completion was emitted. +Release it only after the renderer has finished sampling or has discarded the +frame without accessing it. + +## Runtime selection and fallback + +The fast path is selected per buffer, not globally: + +```text +valid metadata + AND supported EGL/Android import route + AND supported FourCC/modifier/texture target + AND working acquire/release synchronization + -> native GPU sample +otherwise + -> current DMA_BUF_IOCTL_SYNC + CPU upload fallback +``` + +Do not use `TERMUX_X11_FORCE_FLIP=1` as product behavior. It bypasses a known +guard without establishing the missing synchronization and import contracts. + +Log one structured reason when falling back: missing extension, unsupported +format, unsupported modifier, image import error, fence import error, invalid +metadata, or resource exhaustion. Avoid per-frame log spam. + +## Mandatory observability + +The implementation is not testable if it only emits an FPS number. Add bounded +counters and trace slices at the actual ownership boundaries: + +| Signal | Required fields | +| --- | --- | +| Buffer import | generation, buffer ID, frame ID, dimensions, FourCC, modifier, pitch, offset, import route, texture target | +| Import result | success/fallback reason, EGL error, one-time import duration | +| Acquire | fence present, fence FD ownership transfer, wait start/end, timeout/error | +| Draw | frame ID, source buffer ID, damage extent, draw start/end | +| Release | release fence creation, duplicated FD owner, delivery, signal/retirement | +| Reuse | previous release complete, next producer-write start, violation count | +| Presentation | Present serial, completion, idle, Android buffer submission, SurfaceFlinger presentation | +| CPU fallback | invocation count and uploaded bytes | + +Emit Android ATrace slices for import, acquire wait, Lorie draw, +`eglSwapBuffers`, release-fence creation, and buffer retirement. Counters must be +read/reset atomically for one bounded test interval. Never enable per-frame +logcat output in normal builds; it changes scheduling and can manufacture the +stutter being measured. + +Every captured frame must be identified by the tuple: + +```text +connection generation + resource/buffer ID + buffer generation + frame ID +``` + +An FPS counter that cannot distinguish a newly displayed frame from a repeated +or prematurely reused buffer is diagnostic noise. + +## Reproducible test and performance system + +### Evidence manifest + +Every result directory must record: + +- uDroid Git SHA and APK version/signing variant; +- Termux:X11 source SHA plus applied patch list; +- Mesa/Panfrost SHA and build options; +- device model, build fingerprint, Android release, kernel, and GPU driver; +- selected graphics profile and every relevant environment variable; +- resolution, Android refresh mode, X compositor state, and workload command; +- battery state, thermal status before/after, and whether the device was + charging; +- test start/end timestamps and warm-up duration. + +Do not compare runs with different resolution, refresh rate, compositor, +thermal state, or artifact SHA. + +### Controls + +Every qualification includes these paths from the same APK and workload: + +| Control | Purpose | +| --- | --- | +| Synchronized CPU upload | Known-correct visual baseline and current performance baseline. | +| Native import with blocking acquire/release | Isolate format/import correctness from asynchronous synchronization. | +| Native import with explicit fences | Candidate production path. | +| Software rendering | Separate Lorie/Present faults from Panfrost faults. | +| Delayed producer fence | Prove Lorie never samples before producer completion. | +| Delayed consumer fence | Prove Panfrost never reuses before Lorie completion. | + +The two delayed-fence cases are required negative controls. The probe must +detect a deliberately early wait/reuse implementation; otherwise it is not +capable of validating the real implementation. + +### Microprobes + +Run these before a desktop environment: + +1. **Format:** four colored quadrants, alpha strip, single-pixel border, and + padded stride. Compare captured pixels, not human color judgment. +2. **Ordering:** encode frame ID in pixels and telemetry; detect duplicate, + missing, decreasing, or overwritten IDs. +3. **Acquire delay:** submit a buffer with a fence delayed by known intervals; + its pixels must not be sampled early. +4. **Release delay:** hold Lorie's consumer fence; producer reuse must remain + blocked. +5. **Ring pressure:** run two, three, and four buffers at a producer rate above + display refresh; memory must remain bounded and only a documented queueing + policy may drop frames. +6. **Damage:** animate one small rectangle over an otherwise static image and + verify unchanged pixels remain unchanged. +7. **Resize:** alternate two sizes and strides while old frames are in flight. +8. **Lifetime:** destroy/recreate pixmaps, surface generations, and renderer + connections while fences are pending. +9. **Rejection:** exercise invalid size, pitch, offset, plane count, FourCC, + modifier, and closed/non-DMA-BUF FD without crashing. +10. **Soak:** at least 30 minutes with periodic resize and surface detach; + require bounded RSS, FD count, and buffer count. + +The test pattern must be deterministic and machine-checked. Screenshots remain +useful evidence but are not the oracle for channel order, frame order, or +single-frame corruption. + +### Existing repository runners + +- `python3 tools/udroid-debug.py repro-damage` is the embedded Lorie desktop + reproduction gate. Use the embedded APK; a standalone Termux:X11 APK tests a + different `libXlorie.so`. +- `tools/profiling/venus-perfetto.pbtxt` already captures the portable Android, + SurfaceFlinger, scheduling, frequency, and uDroid ATrace sources needed here. + Reuse it initially and name the output for the tested backend; do not create + another trace configuration until a required data source differs. +- `docs/graphics/VENUS_PERFETTO_PROFILING.md` contains capture commands, + FrameTimeline cautions, and baseline SQL. The methodology applies to this + path even though the renderer is Panfrost. +- `dumpsys SurfaceFlinger --timestats` provides a bounded per-layer cadence + control when FrameTimeline attribution is incomplete. + +Capture shape: + +```sh +adb shell dumpsys SurfaceFlinger --timestats -clear +adb shell dumpsys SurfaceFlinger --timestats -enable + +adb shell perfetto --txt -c - \ + -o /data/misc/perfetto-traces/udroid-panfrost-import.perfetto-trace \ + < tools/profiling/venus-perfetto.pbtxt + +adb shell dumpsys SurfaceFlinger --timestats -disable +adb shell dumpsys SurfaceFlinger --timestats -dump \ + > udroid-panfrost-import.timestats.txt +adb pull /data/misc/perfetto-traces/udroid-panfrost-import.perfetto-trace . +``` + +Confirm available device data sources before capture: + +```sh +adb shell perfetto --query --long +``` + +Missing vendor GPU tracepoints are not a reason to abandon the test. Preserve +scheduling, uDroid ATrace, FrameTimeline, and SurfaceFlinger evidence and record +the missing source in the manifest. + +### Metrics and interpretation + +Report distributions, not only averages: + +- displayed unique frames and SurfaceFlinger present intervals; +- FrameTimeline on-time/missed frames and jank types; +- Lorie draw, acquire-wait, release, swap, and retirement p50/p95/p99; +- process/thread CPU time and runnable-but-unscheduled time; +- GPU frequency/busy evidence when the device exposes it; +- CPU upload count/bytes and native-import/fallback counts; +- frame-ID duplicates, gaps, reorderings, and reuse violations; +- RSS/PSS, FD count, active buffer count, and fence count; +- input-to-visible-response latency for the deterministic drag probe. + +Producer FPS measures how quickly the client submits work. It is not physical +scanout FPS. Likewise, a stable 16.67 ms interval on a 60 Hz display is normal +frame pacing, not proof that rendering consumed 16.67 ms. + +### Acceptance gates + +Correctness is absolute: + +- zero wrong pixels, stale frames, frame reorderings, and reuse violations; +- zero unexpected fallback for an eligible buffer; +- zero CPU uploads on the eligible native-import path; +- balanced FD/object ownership after every teardown and soak; +- all rejection probes fail closed without process or display loss. + +Performance is a matched comparison. Run at least five alternating +baseline/candidate pairs after warm-up and report each run, median, p95, and +p99. Accept the candidate only when: + +- displayed cadence and jank are no worse than the CPU baseline; +- Lorie CPU time and uploaded bytes materially decrease beyond run-to-run + noise; +- no new long acquire/release stalls appear; +- surface detach/reattach and resize latency do not regress; +- the visual and lifetime gates remain clean. + +Do not define one universal FPS threshold for every device. Relate cadence to +that device's active refresh period and keep the exact raw data. A desktop +macro-test cannot waive a failed microprobe. + +## Specification-to-test traceability + +| Contract | Primary specification | Required evidence | +| --- | --- | --- | +| DMA-BUF image description | Linux “Exchanging pixel buffers” | Width/height, FourCC, modifier, plane FD, offset, and pitch logged and rejection-tested. | +| EGL DMA-BUF import | `EGL_EXT_image_dma_buf_import` | Exact active-display extension plus successful known-pattern import. | +| Modifier negotiation | `EGL_EXT_image_dma_buf_import_modifiers` | Queried FourCC/modifier pair and correct `external_only` target. | +| EGLImage lifetime | `EGL_KHR_image_base` | Repeated create/destroy and source/target lifetime test. | +| GLES binding | `GL_OES_EGL_image` and `GL_OES_EGL_image_external` | Correct target/shader with exact pixel result. | +| Acquire/release fences | `EGL_ANDROID_native_fence_sync`, `EGL_KHR_wait_sync` | Delayed-fence tests and audited FD ownership. | +| DMA-BUF cache versus device sync | Linux DMA-BUF documentation | CPU fallback uses `DMA_BUF_IOCTL_SYNC`; GPU path proves fence ordering separately. | +| Android-native buffers | AHardwareBuffer, BufferQueue/Gralloc, GLConsumer | Handle identity, acquire-before-read, release-after-read, and generation teardown. | +| X reuse semantics | Present protocol | Present serial/complete/idle correlated with actual renderer retirement. | +| Physical presentation | Perfetto FrameTimeline and SurfaceFlinger TimeStats | Unique-frame cadence correlated with Lorie frame IDs and traces. | + +## Implementation checkpoints + +### G0: metadata and capability probe + +- Log the active EGL vendor/version and relevant EGL/GLES extensions. +- Query FourCC/modifier support when available. +- Record whether a combination is `external_only`. +- Validate one real FD's dimensions, pitch, offset, size, and transport marker. +- Confirm the producer allocation is truly linear before translating `1274` + to `DRM_FORMAT_MOD_LINEAR`. + +Pass condition: an evidence report selects Route A, B, or C without attempting +an unsupported import. On the present Pixel snapshot, Route A must report +unsupported. + +### G1: correlate the existing Panfrost AHB route + +- Trace broker allocation id, Mesa resource/export FD, Present + serial/pixmap/slot, `IdleNotify`, resource destruction, and broker release. +- Re-run the existing pattern and width probes without `glTexSubImage2D`. +- Check orientation, pitch padding, RGB/BGR order, alpha behavior, and checksum. +- Repeat create/destroy 1,000 times while checking FD and EGL object counts. + +Pass condition: exact expected pixels, balanced lifecycle events, and no leak, +crash, or fallback. + +### G2: synchronized ring + +- Run at least three buffers with monotonically increasing frame IDs. +- Start with blocking producer and consumer completion controls. +- Add explicit acquire fence transport. +- Add explicit release fence transport. +- Introduce measured delays on each side and verify no early access or reuse. + +Pass condition: no stale, reordered, partially written, or reused frame under +delay injection. + +### G3: X Present integration + +- Tie renderer completion, not redraw submission, to pixmap idle/reuse. +- Test copy Present and flip Present separately. +- Exercise window resize, occlusion, minimize/restore, surface detach/reattach, + and renderer-process restart. +- Keep CPU fallback available for each failure. + +Pass condition: deterministic damage runner is clean and buffer serials never +overlap producer write with consumer read. + +### G4: desktop qualification + +Run in this order: + +1. colored-pattern and moving-frame microprobes; +2. `glxgears`/`glmark2` with repeated hold-and-drag; +3. Alacritty movement plus character-by-character typing; +4. XFCE without a compositor, then with one; +5. GNOME and Plasma damage runners; +6. SuperTuxKart only after desktop gates pass. + +Collect CPU time, memory bandwidth proxy/copy counters, renderer frame time, +Android frame timeline/jank, Present completion/idle order, and dropped or +repeated frame IDs. Client-reported FPS alone is not a pass condition. + +## Files the eventual patch must reach + +| Area | Likely files | Purpose | +| --- | --- | --- | +| Buffer model | `lorie/buffer.h`, `lorie/buffer.c` | Metadata, import, texture target, FD/image/fence ownership. | +| DRI3 import | `lorie/InitOutput.c` | Translate private transport metadata, validate, and retain pixmap lifetime. | +| Renderer | `lorie/renderer.c` | Wait acquire fence, sample direct texture, emit release completion. | +| Renderer protocol | existing Lorie server/view messages | Carry serials and fences without ID reuse races. | +| Present | `xserver/present/*`, only if required | Delay idle/reuse until consumer completion; avoid protocol-wide hacks. | +| Producer | Panfrost DRI3/WSI and kbase code | Export accurate metadata and acquire completion; consume release completion. | +| Tests | embedded damage runner and native microprobe | Format, stride, ordering, teardown, and fallback gates. | + +Patch the shared buffer/import code once; do not add application-specific fixes +for GNOME, Plasma, Alacritty, or SuperTuxKart. + +## Primary references + +### Khronos + +- [EGL_EXT_image_dma_buf_import](https://registry.khronos.org/EGL/extensions/EXT/EGL_EXT_image_dma_buf_import.txt) +- [EGL_EXT_image_dma_buf_import_modifiers](https://registry.khronos.org/EGL/extensions/EXT/EGL_EXT_image_dma_buf_import_modifiers.txt) +- [EGL_EXT_image_implicit_sync_control](https://registry.khronos.org/EGL/extensions/EXT/EGL_EXT_image_implicit_sync_control.txt) +- [EGL_KHR_image_base](https://registry.khronos.org/EGL/extensions/KHR/EGL_KHR_image_base.txt) +- [GL_OES_EGL_image](https://registry.khronos.org/OpenGL/extensions/OES/OES_EGL_image.txt) +- [GL_OES_EGL_image_external](https://registry.khronos.org/OpenGL/extensions/OES/OES_EGL_image_external.txt) +- [EGL_ANDROID_get_native_client_buffer](https://registry.khronos.org/EGL/extensions/ANDROID/EGL_ANDROID_get_native_client_buffer.txt) +- [EGL_ANDROID_image_native_buffer](https://registry.khronos.org/EGL/extensions/ANDROID/EGL_ANDROID_image_native_buffer.txt) +- [EGL_ANDROID_native_fence_sync](https://registry.khronos.org/EGL/extensions/ANDROID/EGL_ANDROID_native_fence_sync.txt) +- [EGL_KHR_wait_sync](https://registry.khronos.org/EGL/extensions/KHR/EGL_KHR_wait_sync.txt) + +### Linux + +- [DMA-BUF sharing and synchronization](https://docs.kernel.org/driver-api/dma-buf.html) +- [Exchanging pixel buffers](https://docs.kernel.org/userspace-api/dma-buf-alloc-exchange.html) +- [`drm_fourcc.h` format and modifier ABI](https://gitlab.freedesktop.org/mesa/drm/-/blob/main/include/drm/drm_fourcc.h) + +### Android/AOSP + +- [Synchronization framework](https://source.android.com/docs/core/graphics/sync) +- [BufferQueue and Gralloc](https://source.android.com/docs/core/graphics/arch-bq-gralloc) +- [NDK AHardwareBuffer reference](https://developer.android.com/ndk/reference/group/a-hardware-buffer) +- [AOSP META-EGL extension allow-list](https://android.googlesource.com/platform/frameworks/native/+/master/opengl/libs/EGL/egl_platform_entries.cpp) +- [AOSP GLConsumer reference flow](https://android.googlesource.com/platform/frameworks/native/+/master/libs/gui/GLConsumer.cpp) +- [VNDK AHardwareBuffer handle API](https://android.googlesource.com/platform/frameworks/native/+/master/libs/nativewindow/include/vndk/hardware_buffer.h) +- [Perfetto FrameTimeline](https://perfetto.dev/docs/data-sources/frametimeline) +- [Perfetto GPU tracing](https://perfetto.dev/docs/data-sources/gpu) +- [Perfetto ATrace](https://perfetto.dev/docs/data-sources/atrace) + +### X11 protocol and local implementation + +- `third_party/termux-x11/lorie/src/main/cpp/xorgproto/dri3proto.txt` +- `third_party/termux-x11/lorie/src/main/cpp/xorgproto/presentproto.txt` +- `third_party/termux-x11/lorie/src/main/cpp/xserver/include/protocol-versions.h` +- `third_party/termux-x11/lorie/src/main/cpp/xserver/present/` +- [Termux:X11 PR #1148](https://github.com/termux/termux-x11/pull/1148) for the damage and CPU-access-sync control, not as proof of GPU-native import. + +## Decision before coding + +G0 is complete on the connected Pixel and rules out Route A on its active +META-EGL display. Continue by tracing and stress-testing the existing Panfrost +Route B implementation in `tensor-g1-proot-gpu`; Route C remains separate +vendor-specific research. diff --git a/docs/graphics/PR30_STABILITY_HANDOFF_2026-10-26.md b/docs/graphics/PR30_STABILITY_HANDOFF_2026-10-26.md new file mode 100644 index 0000000..b9c971f --- /dev/null +++ b/docs/graphics/PR30_STABILITY_HANDOFF_2026-10-26.md @@ -0,0 +1,29 @@ +# PR #30 stability handoff — 2026-10-26 + +Resume target: [RandomCoderOrg/udroid-app#30](https://github.com/RandomCoderOrg/udroid-app/pull/30), branch `improvements/zink-requirements-check`. This checkpoint began at `b1150c3`; later local commits added desktop input controls. It is not a release qualification. Do not merge or publish merely because the APK builds. + +## Current scope + +- Venus/Zink: device-readable Zink requirements, updated Mesa/Virgl assets, native Venus WSI by default, and Perfetto profiling notes. +- Embedded Termux:X11: DRI3 DMA-BUF/AHardwareBuffer export, Present damage ordering, CPU/GPU ownership transfer, guarded GPU-copy routing, and input/debug probes. The authoritative patch order and production/diagnostic split are in [`patches/termux-x11/README.md`](../../patches/termux-x11/README.md) and [`tools/prepare-termux-x11-source.sh`](../../tools/prepare-termux-x11-source.sh); source is pinned at `0cb0203`. +- The experimental SPHAL/Zink app profile was moved off this PR to `feat/sphal-zink-profile`. Shared AHardwareBuffer and Present patches remain in PR #30 for the other graphics paths. +- Dev-only supervised ADB controls and X11 probes are included. Configurable desktop keyboard and pointer controls were added later. Do not expose arbitrary guest-command execution in release builds. + +The PR check, `Test and build APK`, passed at `5a73892` on 2026-10-25. The later desktop-controls build, unit tests, and dev lint passed locally; recheck hosted CI before merge. Neither result proves compositor stability or performance. The PR description may understate the accumulated scope. + +## Resume gates + +1. Start from a clean checkout at the PR head. Replay standard and diagnostic Termux:X11 source preparation twice each; build the dev APK without bypassing source preparation. Keep local uncommitted work in this worktree intact. +2. Inventory each bundled graphics binary's source commit, build command, ABI/glibc floor, checksum, and license. Rebuild from pinned sources; a locally working binary is not sufficient for redistribution. +3. On the Pixel, test software, any separately installed Panfrost control, and Venus/Zink separately. Record selected renderer, transport route, Present GPU-copy counts/fallback reason, faults, and frame-production timing. Do not infer acceleration or correctness from one visible frame. Qualify SPHAL/Zink on its own branch. +4. Run repeated window move/resize, typing, menu/tooltip, suspend/resume, and app launch/close on XFCE, Plasma X11, and GNOME X11. Treat GNOME as an explicit unresolved stress gate, not proven support. Capture an A/B with upstream Termux:X11 and with GPU Present disabled when a regression appears. +5. Check the diagnostics security boundary, release-default feature flags, upgrade/install behavior, and source-profile cleanup. Run unit tests and PR CI again after any changes. + +## Forks and build documentation to complete + +- Maintain the app patch stack against the pinned Termux:X11 base and track which patches are already upstream. `RandomCoderOrg/termux-x11` exists; decide which remaining patches belong in a maintained fork/PR and keep uDroid-specific JNI bridge work clearly separate. Do not move patches just to make the list shorter. +- Keep Mesa/Virgl source patches and binary provenance in reproducible, pinned forks or build repositories as applicable; document exact revision and artifact-to-source mapping before shipping them. Track SPHAL's separate source and packaging work on `feat/sphal-zink-profile`. Do not assume the app repository's packaged binaries are a complete source recipe. +- Add a user-facing build/install guide for each *qualified* X11 desktop environment: distro/package commands, session selection, recommended graphics profile and compositor setting, known failure modes, and how to collect logs. Mark experimental paths honestly; do not promise Wayland or universal GPU support. +- Add a contributor guide for reproducing the APK and embedded X11/graphics assets from clean sources, including toolchain versions, patch order, test commands, and expected outputs. Keep it separate from the desktop user's guide. + +Local state at checkpoint: Docker Desktop containers were removed on 2026-10-26, including the PostgreSQL container; images, build cache, and volumes were preserved. Recreate build containers as needed. This worktree already contains unrelated modified/untracked graphics docs; preserve and review them before staging anything. diff --git a/docs/graphics/PRESENTATION_CONTRACT_REFERENCE.md b/docs/graphics/PRESENTATION_CONTRACT_REFERENCE.md new file mode 100644 index 0000000..9d16cc7 --- /dev/null +++ b/docs/graphics/PRESENTATION_CONTRACT_REFERENCE.md @@ -0,0 +1,208 @@ +# Android/X11 presentation contract reference + +Status: evidence baseline, 2026-10-20 + +This is the local decision record for uDroid graphics presentation. Read it +before changing the Panfrost, SPHAL/Zink, gfxstream, or embedded Termux:X11 +paths. It records what the specifications require, what this repository +implements, what device tests observed, and what remains unknown. + +Do not use `maybe`, `should`, or `works` without one of these evidence labels: + +- **Specified**: required by a linked platform or API specification. +- **Implemented**: present in the cited local source. +- **Verified**: passed a named repeatable test with retained evidence. +- **Observed**: seen in one run, but not yet a qualification result. +- **Inferred**: consistent with evidence, but not isolated by a control. +- **Unknown**: no evidence yet. This is a test requirement, not a conclusion. + +## The four separate contracts + +A shared buffer does not by itself define correct presentation. Every route has +four independent contracts: + +1. **Storage**: allocation, format, dimensions, planes, stride, offset, modifier, + usage, color order, and alpha semantics. +2. **Transport**: handle/FD ownership and stable resource identity across + process boundaries. +3. **Synchronization and lifetime**: producer completion, consumer completion, + reuse, retirement, resize, and Surface replacement. +4. **Presentation**: damage, X Present completion/idle, Android transaction + completion, frame pacing, and physical display cadence. + +Producer FPS proves none of the other three contracts. A GPU-backed allocation +proves neither zero-copy presentation nor correct synchronization. + +## Architecture correction + +Android BufferQueue is consumer-owned: the consumer creates the queue, the +producer dequeues and queues slots, and the consumer acquires and releases +them. That is the Android framework model, not a universal rule that Lorie must +allocate every AHardwareBuffer. + +A custom broker-owned AHardwareBuffer transport is valid when it carries +authoritative allocation metadata and implements the complete synchronization +and lifetime protocol. uDroid has such a protocol in the gfxstream presenter, +and `SaicharanKandukuri/tensor-g1-proot-gpu` already implements the Panfrost +AHB broker and private DRI3 transport. Therefore: + +- do **not** invert allocation merely because BufferQueue normally does; +- do **not** treat `AHardwareBuffer_sendHandleToUnixSocket()` as a complete + presentation protocol; +- reuse the existing resource/generation/frame and fence semantics before + designing another allocator or transport; +- change allocation ownership only if an isolated import/layout test proves it + necessary. + +## Route matrix + +| Route | Storage and transport | Synchronization/lifetime | Current evidence | Decision | +| --- | --- | --- | --- | --- | +| Raw Panfrost DMA-BUF in Lorie | One FD through private DRI3 marker `1274`; Lorie mmaps and uploads with `glTexSubImage2D` | `DMA_BUF_IOCTL_SYNC` protects CPU access; X Present orders pixmap use | **Implemented** CPU fallback; not GPU-native import | Retain as synchronized fallback and control | +| Panfrost AHardwareBuffer in Lorie | Tensor G1 broker allocates the AHB; Panfrost imports its DMA-BUF; private DRI3 marker `1255` lets Lorie recover the native handle | Mesa blocks for producer completion; its three-slot ring retains each resource until X Present `IdleNotify`; no explicit cross-process fence or correlated teardown trace | **Verified** by broker probes, 33-width lifecycle runs, and full Plasma runs in `tensor-g1-proot-gpu`; desktop qualification remains incomplete | Extend and trace the existing route; do not rebuild it | +| SPHAL/Zink AHardwareBuffer in Lorie | Private DRI3 markers `1255`/`1257`; Lorie receives an AHB and imports an `EGLImage` | Relies on Vulkan external queue ownership, implicit DMA-BUF synchronization, and X Present ordering; no explicit per-frame acquire/release/reuse/retire protocol | **Observed** Pixel runs offloaded Present copies; desktop damage has also been observed | Instrument and adopt the validated lifecycle contract before qualification | +| Gfxstream app presenter v4 | Public AHB socket helpers plus `(resource, generation, frame)` packets | Producer fence, presentation signal, release signal, reuse-ready, retirement, and Surface generation | **Verified** by local validators and retained Pixel stress results | Reference implementation for lifecycle semantics | +| Direct app EGL DMA-BUF import | FourCC plus plane FD/offset/pitch/modifier through `EGL_EXT_image_dma_buf_import` | Needs an explicit acquire/release design in addition to EGLImage lifetime | **Verified unavailable** on the tested Pixel META-EGL display | Capability-gated only; not the Pixel implementation | +| Direct vendor/SPHAL EGL DMA-BUF import | Independent vendor EGL display/context and queried formats/modifiers | Unknown until an isolated vendor-display probe proves it | **Unknown** | Device-specific research route, never mix with META-EGL objects | + +## Existing validated lifecycle protocol + +The packet ABI is in `app/src/main/cpp/ahb_transport_protocol.h`. Protocol v4 +uses the following identity: + +```text +(connection epoch, resource id, resource generation, frame id) +``` + +Its lifecycle is: + +```text +register AHB generation + -> acquire frame with producer-completion FD + -> consumer waits and presents + -> presentation-completion and buffer-release signals + -> producer reports reuse-ready + -> retire generation after all users drain +``` + +The presenter implementation is +`app/src/main/cpp/ahb_surface_presenter.cpp`. It rejects unknown or stale +generations, non-monotonic frames, premature reuse, and retirement while a +resource is in flight. It also tracks Android Surface generations. + +The protocol validators are: + +- `tools/graphics/winsys_trace_validator.py`: validates one lifecycle trace; +- `tools/graphics/winsys_dual_trace_validator.py`: correlates producer and + presenter traces; +- `tools/graphics/test_winsys_trace_validator.py` and + `tools/graphics/test_winsys_dual_trace_validator.py`: rejection fixtures. + +This protocol is a semantic reference, not proof that its exact socket ABI can +be copied unchanged into X Present. + +## What the specifications settle + +| Topic | Authoritative source | Settled conclusion | +| --- | --- | --- | +| BufferQueue ownership | [AOSP BufferQueue and Gralloc](https://source.android.com/docs/core/graphics/arch-bq-gralloc) | The consumer owns the queue; producer dequeues/fills/queues and consumer acquires/uses/releases. Handles refer to shared storage rather than copying pixels. Gralloc usage can select vendor-specific layouts. | +| Android fence direction | [AOSP synchronization framework](https://source.android.com/docs/core/graphics/sync) | A producer may hand off a buffer before GPU work finishes with an acquire fence; the consumer returns a release fence when it no longer uses that buffer. | +| DMA-BUF CPU access | [Linux DMA-BUF documentation](https://docs.kernel.org/driver-api/dma-buf.html) | `DMA_BUF_IOCTL_SYNC` brackets CPU cache access. It does not provide mutual exclusion against other users and does not replace producer/consumer completion ordering. Exported FDs must be close-on-exec. | +| DMA-BUF image description | [Linux buffer exchange](https://docs.kernel.org/userspace-api/dma-buf-alloc-exchange.html) | An FD is not enough; format, dimensions, planes, offsets, strides, and modifiers define the image. | +| EGL DMA-BUF import | [EGL_EXT_image_dma_buf_import](https://registry.khronos.org/EGL/extensions/EXT/EGL_EXT_image_dma_buf_import.txt) | Width, height, DRM FourCC, plane FD, offset, and pitch are required. EGL retains the imported object, but FD ownership is not transferred to EGL. | +| EGL native fence ownership | [EGL_ANDROID_native_fence_sync](https://registry.khronos.org/EGL/extensions/ANDROID/EGL_ANDROID_native_fence_sync.txt) | Importing a native-fence FD transfers that FD to EGL. Export uses a duplicated FD owned by the caller. Flush is required to materialize a fence created without an input FD. | +| Android EGLImage import | [EGL_ANDROID_image_native_buffer](https://registry.khronos.org/EGL/extensions/ANDROID/EGL_ANDROID_image_native_buffer.txt) | An Android native buffer can become an EGLImage only through the advertised extension and a valid native buffer object. | +| Vulkan AHB memory | [Vulkan external Android hardware buffer](https://github.khronos.org/Vulkan-Site/spec/latest/chapters/memory.html#memory-external-android-hardware-buffer) | Vulkan import/export must use the AHB properties, usage, format, external-memory, and dedicated-allocation rules reported by the implementation. | +| AOSP consumer lifecycle | [AOSP GLConsumer](https://android.googlesource.com/platform/frameworks/native/+/c27d3ddd2f8796963144666aa7d3d006ddcad35e/libs/gui/GLConsumer.cpp) | A real consumer correlates slots and frame numbers, replaces stale EGLImages, waits before sampling, and attaches release synchronization before returning a slot. | +| X pixmap reuse | `third_party/termux-x11/lorie/src/main/cpp/xorgproto/presentproto.txt` | Present completion and idle are different events; producer reuse must follow actual idle/retirement, not redraw submission. | + +The links are the source of truth. This file intentionally records conclusions +instead of copying full external specifications, whose revisions and licenses +differ. + +## Local implementation map + +### Producer and private DRI3 transport + +The Panfrost AHB producer is implemented in the companion repository +`SaicharanKandukuri/tensor-g1-proot-gpu`: + +- `tensor-g1/ahardwarebuffer/tensor-ahb-service.c`: Bionic AHB owner, pool, + handle transfer, and dead-client reclamation. +- `src/gallium/drivers/panfrost/pan_resource.c`: broker allocation, Kbase + DMA-BUF import, and private modifier export. +- `src/gallium/frontends/dri/drisw.c`: blocking producer fence wait and the + three-resource Present ring. +- `src/egl/drivers/dri2/platform_x11.c`: DRI3 pixmap creation plus Present + serial, completion, and idle handling. +- `tensor-g1/termux-x11/tensor-g1-dri3.patch`: matching Termux:X11 native + AHardwareBuffer import. + +### Lorie consumer + +- `third_party/termux-x11/lorie/src/main/cpp/lorie/buffer.c`: FD mmap/upload + fallback and AHB EGLImage binding. +- `third_party/termux-x11/lorie/src/main/cpp/lorie/renderer.c`: source texture + sampling and final Android Surface draw. +- `third_party/termux-x11/lorie/src/main/cpp/xserver/present/`: X Present + completion and idle machinery. + +### App-native reference presenter + +- `app/src/main/cpp/ahb_transport_protocol.h`: packet ABI and FD order. +- `app/src/main/cpp/ahb_surface_presenter.cpp`: resource registry, fence + handling, SurfaceControl callbacks, reuse, retirement, and trace events. +- `docs/graphics/GFXSTREAM_WINSYS_CONTRACT.md`: protocol invariants and gates. +- `docs/graphics/gfxstream-surface-presenter.md`: retained device results and + presentation history. + +## Evidence status + +| Claim | Status | Evidence or missing proof | +| --- | --- | --- | +| Pixel META-EGL can import raw DMA-BUF with `EGL_LINUX_DMA_BUF_EXT` | **Verified false** for the tested display | G0 reports no `EGL_EXT_image_dma_buf_import` or modifiers extension. | +| Pixel META-EGL can import AHardwareBuffer as EGLImage | **Verified** | G0 extensions plus existing Lorie AHB route. | +| AHB handles alone prevent damage | **Verified false** | Old private Lorie transport used AHB yet desktop damage was observed. | +| Consumer-owned allocation is required by the relevant APIs | **Not specified** | It is BufferQueue architecture; the validated custom presenter accepts registered producer resources. Allocation direction remains a route-specific design choice. | +| Explicit identity and lifecycle are required for deterministic validation | **Specified and implemented** | Existing contract and validators reject stale/reused/in-flight resources. | +| Implicit synchronization is the cause of current desktop damage | **Unknown** | Successful and damaged runs exist; no paired trace has isolated synchronization from damage/Present ordering. | +| AHB format number determines sampled channel order | **Verified false** | The `1255`/`1257` SuperTuxKart correction required content-semantics testing. | +| Client FPS equals displayed FPS | **Verified false** | Async producer throughput can exceed Android presentation cadence. Use FrameTimeline and frame IDs. | + +## Next implementation gate + +The one-buffer bridge and three-buffer X Present ring already exist in +`tensor-g1-proot-gpu`. The next gate is deterministic validation of that route: + +1. correlate broker allocation id, Mesa resource, Present serial/pixmap/slot, + `IdleNotify`, resource destruction, and broker release in one trace; +2. prove normal and abnormal retirement on resize, drawable destruction, + Surface replacement, client disconnect, and X-server restart; +3. verify the known pixel pattern and delayed-producer controls without a CPU + fallback; +4. only add an explicit fence packet if a control demonstrates that the + existing blocking producer wait plus Present-idle lifetime is insufficient. + +Allocation inversion is a separate A/B experiment, not the starting point. + +GNOME, Plasma, Alacritty, and SuperTuxKart are qualification workloads, not +debuggers for G1. Do not run them as the primary test until the known-pattern, +delayed-fence, stale-generation, reuse, and retirement controls pass. + +## Required evidence for any future claim + +Record all of the following together: + +- source commit and APK/build identity; +- device, Android build, GPU, EGL/Vulkan implementation, and refresh rate; +- selected route and fallback reason; +- resource/generation/frame events on both sides; +- all FD creation, transfer, wait, signal, and close ownership; +- FourCC, dimensions, plane count, stride, offset, modifier, AHB usage, and + sampled color semantics; +- X Present complete/idle and Android presentation/release completion; +- deterministic pixel/checksum result and FrameTimeline cadence; +- CPU fallback and software-rendering controls. + +Without that bundle, label the result **Observed** or **Unknown**, never +**Verified**. diff --git a/docs/graphics/README.md b/docs/graphics/README.md new file mode 100644 index 0000000..d93b297 --- /dev/null +++ b/docs/graphics/README.md @@ -0,0 +1,24 @@ +# Graphics documentation map + +Start with the document matching the question. Do not infer one graphics path +from results produced by another. + +| Question | Read first | +| --- | --- | +| Why does an AHardwareBuffer or DMA-BUF path damage, repeat, or reorder frames? | [`PRESENTATION_CONTRACT_REFERENCE.md`](PRESENTATION_CONTRACT_REFERENCE.md) | +| What must the next Lorie GPU-import patch prove? | [`LORIE_DMABUF_GPU_IMPORT.md`](LORIE_DMABUF_GPU_IMPORT.md) | +| What is the gfxstream resource/fence/reuse contract? | [`GFXSTREAM_WINSYS_CONTRACT.md`](GFXSTREAM_WINSYS_CONTRACT.md) | +| What did the app-native AHB presenter prove? | [`gfxstream-surface-presenter.md`](gfxstream-surface-presenter.md) | +| How is the remaining gfxstream work staged? | [`GFXSTREAM_SHIPPING_TODO.md`](GFXSTREAM_SHIPPING_TODO.md) | +| How should Venus/gfxstream frame latency be captured? | [`VENUS_PERFETTO_PROFILING.md`](VENUS_PERFETTO_PROFILING.md) | +| Which Android Linux Terminal implementation details are reusable? | [`AOSP_TERMINAL_REFERENCE.md`](AOSP_TERMINAL_REFERENCE.md) | +| What did the semaphore and ownership investigation establish? | [`2026-09-06-semaphore-lifecycle.md`](2026-09-06-semaphore-lifecycle.md) | +| What did the barrier/reuse investigation establish? | [`2026-09-10-barrier-route.md`](2026-09-10-barrier-route.md) | +| Where was the GNOME/Venus investigation paused? | [`GNOME_VENUS_CHECKPOINT_2026-10-04.md`](GNOME_VENUS_CHECKPOINT_2026-10-04.md) | + +Evidence retained under `docs/graphics/evidence/` belongs to its named +checkpoint. A client FPS number, screenshot, or standalone Termux:X11 run does +not establish the uDroid embedded presentation route. + +The experimental SPHAL/Zink profile and its documentation live on the separate +[`feat/sphal-zink-profile` branch](https://github.com/RandomCoderOrg/udroid-app/blob/feat/sphal-zink-profile/docs/graphics/SPHAL_ZINK_PROFILE.md), not in PR #30. diff --git a/docs/graphics/VENUS_PERFETTO_PROFILING.md b/docs/graphics/VENUS_PERFETTO_PROFILING.md new file mode 100644 index 0000000..28feb90 --- /dev/null +++ b/docs/graphics/VENUS_PERFETTO_PROFILING.md @@ -0,0 +1,113 @@ +# Venus and Zink Perfetto profiling + +Use this trace when a Venus or Venus/Zink desktop feels slow, misses frames, +or stalls while the renderer still appears healthy. It separates guest work, +Lorie presentation, and Android composition instead of treating one FPS number +as the cause. + +## Capture + +1. Select **Venus (experimental)**, start the desktop, and open **Display**. +2. Start a continuously rendered workload. A static desktop or cursor-only + movement is not a useful graphics baseline. +3. Record the 15-second trace while reproducing the slowdown: + +```sh +adb shell perfetto --txt -c - \ + -o /data/misc/perfetto-traces/udroid-venus.perfetto-trace \ + < tools/profiling/venus-perfetto.pbtxt +adb pull /data/misc/perfetto-traces/udroid-venus.perfetto-trace . +``` + +Open the trace in [Perfetto UI](https://ui.perfetto.dev/). Keep the workload, +resolution, compositor setting, graphics profile, device temperature, and app +build identical when comparing two traces. + +The config records the uDroid process, CPU scheduling and frequency, Mali job +submission/completion, GPU frequency and memory, SurfaceFlinger frames and +layers, and FrameTimeline. It intentionally does not request +`gpu.renderstages`: the Pixel 6a used for this checkpoint does not publish that +data source. Check another device with: + +```sh +adb shell perfetto --query --long +``` + +Unavailable vendor ftrace events may be omitted by that device. Keep the +portable scheduling, SurfaceFlinger, and uDroid trace sources enabled. + +## Read the trace + +Find these tracks in order: + +1. The guest workload and `virgl_render_server` threads: look for runnable but + unscheduled gaps or a saturated CPU. +2. `org.randomcoder.udroid.dev`: expand the Lorie renderer thread and inspect + `uDroid/Lorie draw`, `root fence wait`, `eglSwapBuffers`, and + `post-swap fence wait` slices. +3. GPU frequency and Mali job tracks: confirm whether the GPU is busy while a + Lorie wait is open. +4. The uDroid `SurfaceView` under SurfaceFlinger: compare buffer arrival with + expected and actual presentation. + +A stable 16.67 ms interval is one frame at 60 Hz, not evidence of 16.67 ms of +rendering. Investigate intervals that repeatedly become 33.3 ms or longer, +long fence slices, scheduling gaps, or buffers that arrive after their desired +presentation time. + +FrameTimeline does not attribute every `SurfaceView` producer correctly on +every Android release. Treat it as Android composition evidence and correlate +it with the Lorie slices and SurfaceFlinger layer rather than using it alone. + +Use **Query (SQL)** for a reproducible summary: + +```sql +SELECT name, + count(*) AS frames, + round(avg(dur) / 1000000.0, 3) AS avg_ms, + round(max(dur) / 1000000.0, 3) AS max_ms +FROM slice +WHERE name LIKE 'uDroid/Lorie%' +GROUP BY name +ORDER BY name; +``` + +```sql +SELECT present_type, on_time_finish, jank_type, count(*) AS frames +FROM actual_frame_timeline_slice +GROUP BY present_type, on_time_finish, jank_type +ORDER BY frames DESC; +``` + +```sql +SELECT min(value) AS min_khz, + round(avg(value), 1) AS avg_khz, + max(value) AS max_khz +FROM counter +JOIN counter_track ON counter_track.id = counter.track_id +WHERE counter_track.type = 'gpu_frequency'; +``` + +## Pixel 6a checkpoint + +On Android 17 with uDroid `0.1.4+dev`, a 15-second Venus/Zink `glxgears` +capture produced 898 Lorie frames and 899 on-time SurfaceFlinger presents with +no reported jank. Lorie draw averaged 4.37 ms; the root-fence wait averaged +1.39 ms, `eglSwapBuffers` 0.79 ms, and the post-swap wait 0.73 ms. GPU +frequency averaged 171 MHz and peaked at 434 MHz. + +That run held the 60 Hz presentation budget. It also emitted per-frame +`writeReleaseFence ... Broken pipe` messages, so treat that message as a +separate Android buffer-release-channel compatibility problem, not proof of a +slow frame by itself. + +## Comparison gate + +Use at least three matched captures. A change is an improvement only if it +reduces p95/p99 missed-frame intervals or CPU/GPU work without introducing +corruption, transparent frames, resize failures, or teardown hangs. Do not +promote a Zink environment option from a single FPS result. + +References: [Perfetto FrameTimeline](https://perfetto.dev/docs/data-sources/frametimeline), +[GPU tracing](https://perfetto.dev/docs/data-sources/gpu), and +[ATrace](https://perfetto.dev/docs/data-sources/atrace). diff --git a/docs/graphics/gfxstream-surface-presenter.md b/docs/graphics/gfxstream-surface-presenter.md index 11c57af..fbe0aeb 100644 --- a/docs/graphics/gfxstream-surface-presenter.md +++ b/docs/graphics/gfxstream-surface-presenter.md @@ -251,12 +251,14 @@ transport now separates the physical buffer format from its content semantics: - `1255`: RGBA AHardwareBuffer carrying BGRA-compatible content; the X11 renderer applies its channel swizzle. -- `1257`: RGBA AHardwareBuffer carrying native RGBA content; the X11 renderer - samples it without the swizzle. - -Before `1257`, direct Vulkan rendered the LunarG texture with visibly swapped -brown channels. The paired client/server change restores its cyan/teal colors -without regressing GLX. The server publishes the versioned root-window +- `1257`: RGBA AHardwareBuffer whose sampled bytes match the X11 root-buffer + contract; the GPU copy does not swizzle it, and the final root draw performs + the single required Android presentation swizzle. + +Treating `1257` as already Android-display-ready added a second channel swap; +SuperTuxKart exposed it as blue UI and kart colors. Matching the imported +buffer to the X11 root contract restores normal red, green, and yellow colors +without disabling GPU Present. The server publishes the versioned root-window property `_UDROID_X11_BUFFER_TRANSPORT`; the verified value is `1, 11, 1255, 0, 1257, 0`, advertising AHardwareBuffer socket transport, RGBA content semantics, and GPU-copy support. It deliberately does not advertise diff --git a/patches/termux-x11/0000-xserver-patch-semantic-idempotence.patch b/patches/termux-x11/0000-xserver-patch-semantic-idempotence.patch index a33ce47..570d7dc 100644 --- a/patches/termux-x11/0000-xserver-patch-semantic-idempotence.patch +++ b/patches/termux-x11/0000-xserver-patch-semantic-idempotence.patch @@ -1,3 +1,9 @@ +termux-x11: make nested Xserver patching idempotent + +Later uDroid patches modify files already touched by xserver.patch, making a +reverse dry-run unreliable. Record the base patch semantically before applying +the rest of the stack. + --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,17 @@ find_package(Python3 REQUIRED) diff --git a/patches/termux-x11/0001-android-xlocale-include-order.patch b/patches/termux-x11/0001-android-xlocale-include-order.patch index 8292ea0..5bc4132 100644 --- a/patches/termux-x11/0001-android-xlocale-include-order.patch +++ b/patches/termux-x11/0001-android-xlocale-include-order.patch @@ -1,3 +1,8 @@ +libX11: use Bionic's xlocale compatibility header + +Including locale.h directly selects the glibc-oriented declarations. Include +Android's compatibility header when building the embedded libX11 instead. + --- a/include/X11/Xlocale.h +++ b/include/X11/Xlocale.h @@ -32,6 +32,10 @@ diff --git a/patches/termux-x11/0002-udroid-native-server-entrypoint.patch b/patches/termux-x11/0002-udroid-native-server-entrypoint.patch index 89c38d3..b906fb1 100644 --- a/patches/termux-x11/0002-udroid-native-server-entrypoint.patch +++ b/patches/termux-x11/0002-udroid-native-server-entrypoint.patch @@ -1,3 +1,8 @@ +lorie: expose the X server entry point to uDroid + +uDroid embeds libXlorie without Termux:X11's CmdEntryPoint class. Export its JNI +name while keeping both applications on the same startup implementation. + --- a/cmdentrypoint.c +++ b/cmdentrypoint.c @@ -69,8 +69,7 @@ static Bool detectTracer(void) diff --git a/patches/termux-x11/0003-udroid-renderer-bridge.patch b/patches/termux-x11/0003-udroid-renderer-bridge.patch index 34d006a..28412fd 100644 --- a/patches/termux-x11/0003-udroid-renderer-bridge.patch +++ b/patches/termux-x11/0003-udroid-renderer-bridge.patch @@ -1,3 +1,8 @@ +lorie: add the uDroid renderer bridge + +The embedded display uses uDroid JNI names rather than Termux:X11's classes. +Route both consumers through the shared renderer and X socket helpers. + --- a/cmdentrypoint.c +++ b/cmdentrypoint.c @@ -511,8 +511,7 @@ void DDXNotifyFocusChanged(void) { diff --git a/patches/termux-x11/0004-udroid-batched-native-touch.patch b/patches/termux-x11/0004-udroid-batched-native-touch.patch index 81287f3..b74bb68 100644 --- a/patches/termux-x11/0004-udroid-batched-native-touch.patch +++ b/patches/termux-x11/0004-udroid-batched-native-touch.patch @@ -1,3 +1,9 @@ +lorie: batch Android multi-touch frames + +Send every contact from one Android sample as a single ordered X11 update. +Publishing contacts independently exposes partial state and can reorder or +strand touches under load. + --- a/lorie.h +++ b/lorie.h @@ -100,6 +100,7 @@ typedef enum { diff --git a/patches/termux-x11/0005-dmabuf-cpu-read-sync.patch b/patches/termux-x11/0005-dmabuf-cpu-read-sync.patch index 49ef860..fb7626d 100644 --- a/patches/termux-x11/0005-dmabuf-cpu-read-sync.patch +++ b/patches/termux-x11/0005-dmabuf-cpu-read-sync.patch @@ -1,3 +1,10 @@ +lorie: synchronize DMA-BUF reads before GLES upload + +A GPU producer may leave cache lines invisible to the CPU. Bracket mapped reads +with DMA_BUF_IOCTL_SYNC and retain the shared-memory path for ordinary file +descriptors. This follows the Linux DMA-BUF userspace contract and the work in +termux/termux-x11#1148. + --- a/buffer.c +++ b/buffer.c @@ -7,12 +7,17 @@ @@ -18,12 +25,11 @@ #include #include #include -@@ -33,12 +38,80 @@ struct LorieBuffer { +@@ -33,12 +38,57 @@ struct LorieBuffer { int fd; size_t size; off_t offset; + int8_t dmaBufSyncSupport; -+ uint32_t debugUploadCount; GLuint id; EGLImage image; @@ -73,49 +79,25 @@ + buffer->fd, strerror(errno)); + } +} -+ -+static void debugMappedBuffer(LorieBuffer *buffer) { -+ if (!getenv("TERMUX_X11_DEBUG") || (buffer->debugUploadCount++ % 120) != 0) -+ return; -+ -+ const uint32_t *words = buffer->desc.data; -+ size_t wordCount = buffer->size / sizeof(*words); -+ size_t step = wordCount > 256 ? wordCount / 256 : 1; -+ uint32_t hash = 2166136261u; -+ uint32_t nonzero = 0; -+ for (size_t index = 0; index < wordCount; index += step) { -+ uint32_t value = words[index]; -+ nonzero += value != 0; -+ hash = (hash ^ value) * 16777619u; -+ } -+ -+ dprintf(2, -+ "LorieBuffer: fd %d DMA-BUF=%s sampled_nonzero=%u hash=%08x size=%zu\n", -+ buffer->fd, -+ buffer->dmaBufSyncSupport == DMA_BUF_SYNC_SUPPORTED ? "yes" : "no", -+ nonzero, hash, buffer->size); -+} + __attribute__((unused)) static int memfd_create(const char *name, unsigned int flags) { #ifndef __NR_memfd_create -@@ -356,6 +429,8 @@ __LIBC_HIDDEN__ void LorieBuffer_recvHandleFromUnixSocket(int socketFd, LorieBuf +@@ -356,6 +406,7 @@ __LIBC_HIDDEN__ void LorieBuffer_recvHandleFromUnixSocket(int socketFd, LorieBuf read(socketFd, &buffer, sizeof(buffer)); buffer.image = NULL; // Only for process-local use + buffer.dmaBufSyncSupport = DMA_BUF_SYNC_UNKNOWN; -+ buffer.debugUploadCount = 0; if (buffer.desc.type == LORIEBUFFER_FD) { size_t size = buffer.desc.stride * buffer.desc.height * sizeof(uint32_t); buffer.fd = ancil_recv_fd(socketFd); -@@ -421,8 +496,13 @@ __LIBC_HIDDEN__ void LorieBuffer_bindTexture(LorieBuffer *buffer) { +@@ -421,8 +472,12 @@ __LIBC_HIDDEN__ void LorieBuffer_bindTexture(LorieBuffer *buffer) { return; glBindTexture(GL_TEXTURE_2D, buffer->id); - if (buffer->desc.type == LORIEBUFFER_FD) + if (buffer->desc.type == LORIEBUFFER_FD) { + bool dmaBufRead = dmaBufCpuReadBegin(buffer); -+ debugMappedBuffer(buffer); glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, buffer->desc.stride, buffer->desc.height, buffer->desc.format == AHARDWAREBUFFER_FORMAT_B8G8R8A8_UNORM ? GL_BGRA_EXT : GL_RGBA, GL_UNSIGNED_BYTE, buffer->desc.data); + if (dmaBufRead) + dmaBufCpuReadEnd(buffer); diff --git a/patches/termux-x11/0006-ahardwarebuffer-external-texture-sampling.patch b/patches/termux-x11/0006-ahardwarebuffer-external-texture-sampling.patch index 9e3dce5..2c24555 100644 --- a/patches/termux-x11/0006-ahardwarebuffer-external-texture-sampling.patch +++ b/patches/termux-x11/0006-ahardwarebuffer-external-texture-sampling.patch @@ -1,8 +1,14 @@ +lorie: sample AHardwareBuffers as external textures + +Android BufferQueue images use external-image storage. Keep the existing 2D +view for framebuffer use, but sample supported AHardwareBuffers through +GL_TEXTURE_EXTERNAL_OES to avoid incomplete or stale images on Mali. + --- a/buffer.c +++ b/buffer.c -@@ -42,6 +42,7 @@ struct LorieBuffer { - uint32_t debugUploadCount; - +@@ -41,6 +41,7 @@ struct LorieBuffer { + int8_t dmaBufSyncSupport; + GLuint id; + GLuint externalId; EGLImage image; @@ -17,14 +23,13 @@ if (eglGetCurrentDisplay() && buffer->image) eglDestroyImageKHR(eglGetCurrentDisplay(), buffer->image); -@@ -428,6 +431,8 @@ __LIBC_HIDDEN__ void LorieBuffer_recvHandleFromUnixSocket(int socketFd, LorieBuf +@@ -404,5 +407,7 @@ __LIBC_HIDDEN__ void LorieBuffer_recvHandleFromUnixSocket(int socketFd, LorieBuf read(socketFd, &buffer, sizeof(buffer)); buffer.image = NULL; // Only for process-local use + buffer.id = 0; + buffer.externalId = 0; buffer.dmaBufSyncSupport = DMA_BUF_SYNC_UNKNOWN; - buffer.debugUploadCount = 0; if (buffer.desc.type == LORIEBUFFER_FD) { @@ -489,6 +494,22 @@ __LIBC_HIDDEN__ void LorieBuffer_attachToGL(LorieBuffer* buffer) { // The image will be updated in redraw call because of `drawRequested` flag, so we are not uploading pixels diff --git a/patches/termux-x11/0007-ahardwarebuffer-content-semantics.patch b/patches/termux-x11/0007-ahardwarebuffer-content-semantics.patch index 30ed8d0..7bcef2d 100644 --- a/patches/termux-x11/0007-ahardwarebuffer-content-semantics.patch +++ b/patches/termux-x11/0007-ahardwarebuffer-content-semantics.patch @@ -1,3 +1,9 @@ +lorie: define AHardwareBuffer content and ownership semantics + +Give the private DRI3 modifiers explicit BGRA and RGBA meanings and bound the +socket handshake. This prevents swapped colors and releases received buffers +when transport fails. + --- a/InitOutput.c +++ b/InitOutput.c @@ -14,6 +14,8 @@ @@ -86,7 +92,7 @@ + check(!(priv->buffer = LorieBuffer_wrapAHardwareBuffer(received_buffer)), "DRI3: LorieBuffer_wrapAHardwareBuffer failed."); + received_buffer = NULL; + if (modifier == AHARDWAREBUFFER_RGBA_SOCKET_FD) -+ LorieBuffer_setNeedsBgraSwizzle(priv->buffer, false); ++ LorieBuffer_setNeedsBgraSwizzle(priv->buffer, true); screen->ModifyPixmapHeader(pixmap, desc.width, desc.height, 0, 0, desc.stride * 4, NULL); if (lorieServerDebugEnabled) diff --git a/patches/termux-x11/0008-advertise-buffer-transport-protocol.patch b/patches/termux-x11/0008-advertise-buffer-transport-protocol.patch index 2d77d54..3303eff 100644 --- a/patches/termux-x11/0008-advertise-buffer-transport-protocol.patch +++ b/patches/termux-x11/0008-advertise-buffer-transport-protocol.patch @@ -1,3 +1,9 @@ +lorie: advertise the private buffer transport contract + +Publish a versioned root-window property so guest producers can discover AHB +socket transport, channel semantics, and GPU Present support instead of +guessing. The property describes capabilities without enabling them. + --- a/InitOutput.c +++ b/InitOutput.c @@ -40,6 +40,9 @@ @@ -58,3 +64,83 @@ return TRUE; } /* end lorieScreenInit */ +@@ -1184,32 +1219,56 @@ + return NULL; + } +- +-static int lorieGetFormats(__unused ScreenPtr screen, CARD32 *num_formats, CARD32 **formats) { +- static CARD32 format = DRM_FORMAT_ARGB8888; +- *num_formats = 1; +- *formats = &format; +- return TRUE; +-} +- +-static int lorieGetModifiers(__unused ScreenPtr screen, uint32_t format, uint32_t *num_modifiers, uint64_t **modifiers) { +- static uint64_t modifier = DRM_FORMAT_MOD_LINEAR; +- +- if (format != DRM_FORMAT_ARGB8888 && format != DRM_FORMAT_XRGB8888) { +- *num_modifiers = 0; +- *modifiers = NULL; +- return TRUE; +- } +- +- *num_modifiers = 1; +- *modifiers = &modifier; +- return TRUE; +-} +- ++ ++static int lorieGetFormats(__unused ScreenPtr screen, CARD32 *num_formats, CARD32 **formats) { ++ static CARD32 format = DRM_FORMAT_ARGB8888; ++ *num_formats = 1; ++ *formats = &format; ++ return TRUE; ++} ++ ++static uint64_t lorieSupportedModifiers[] = { ++ 1255, /* AHardwareBuffer BGRA socket transport */ ++ 1257, /* AHardwareBuffer RGBA socket transport */ ++ DRM_FORMAT_MOD_LINEAR, ++}; ++ ++static int lorieGetModifiers(__unused ScreenPtr screen, uint32_t format, uint32_t *num_modifiers, uint64_t **modifiers) { ++ if (format != DRM_FORMAT_ARGB8888 && format != DRM_FORMAT_XRGB8888) { ++ *num_modifiers = 0; ++ *modifiers = NULL; ++ return TRUE; ++ } ++ ++ *num_modifiers = sizeof(lorieSupportedModifiers) / sizeof(lorieSupportedModifiers[0]); ++ *modifiers = lorieSupportedModifiers; ++ return TRUE; ++} ++ ++static int lorieGetDrawableModifiers(__unused DrawablePtr draw, uint32_t format, ++ uint32_t *num_modifiers, uint64_t **modifiers) { ++ uint64_t *copy; ++ ++ if (format != DRM_FORMAT_ARGB8888 && format != DRM_FORMAT_XRGB8888) { ++ *num_modifiers = 0; ++ *modifiers = NULL; ++ return TRUE; ++ } ++ ++ copy = malloc(sizeof(lorieSupportedModifiers)); ++ if (!copy) ++ return FALSE; ++ ++ memcpy(copy, lorieSupportedModifiers, sizeof(lorieSupportedModifiers)); ++ *num_modifiers = sizeof(lorieSupportedModifiers) / sizeof(lorieSupportedModifiers[0]); ++ *modifiers = copy; ++ return TRUE; ++} ++ + static dri3_screen_info_rec lorieDri3Info = { + .version = 2, + .fds_from_pixmap = FalseNoop, + .pixmap_from_fds = loriePixmapFromFds, + .get_formats = lorieGetFormats, + .get_modifiers = lorieGetModifiers, +- .get_drawable_modifiers = FalseNoop ++ .get_drawable_modifiers = lorieGetDrawableModifiers + }; diff --git a/patches/termux-x11/0009-defer-gpu-only-present-while-detached.patch b/patches/termux-x11/0009-defer-gpu-only-present-while-detached.patch index 7e5ccd3..063296b 100644 --- a/patches/termux-x11/0009-defer-gpu-only-present-while-detached.patch +++ b/patches/termux-x11/0009-defer-gpu-only-present-while-detached.patch @@ -1,3 +1,9 @@ +present: defer GPU-only buffers while the renderer is detached + +GPU-only AHardwareBuffers cannot fall back to EXA when the Android renderer is +temporarily unavailable. Retry them on a later fake vblank instead of entering +an invalid CPU mapping path. + --- a/lorie/InitOutput.c +++ b/lorie/InitOutput.c @@ -988,6 +988,19 @@ Bool lorieGpuCopyIsDone(uint64_t serial) { diff --git a/patches/termux-x11/0010-disable-gpu-present-copy.patch b/patches/termux-x11/0010-disable-gpu-present-copy.patch deleted file mode 100644 index 4a5d4bc..0000000 --- a/patches/termux-x11/0010-disable-gpu-present-copy.patch +++ /dev/null @@ -1,12 +0,0 @@ ---- a/InitOutput.c -+++ b/InitOutput.c -@@ -848,6 +848,10 @@ Bool lorieTryScheduleGpuCopy(PixmapPtr pixmap, PixmapPtr dst, RegionPtr update, - uint64_t *out_serial, void **out_dst_buffer) { -+ /* uDroid standard profile: use Xorg's copy path until GPU Present copies are reliable. */ -+ gpuCopyAttempts++; -+ return FALSE; -+ - LorieBuffer *srcBuffer, *dstBuffer; - LoriePixmapPriv *priv; - const LorieBuffer_Desc *desc, *dstDesc; - LorieGpuCopyEntry *entry; diff --git a/patches/termux-x11/0011-lorie-perfetto-slices.patch b/patches/termux-x11/0011-lorie-perfetto-slices.patch new file mode 100644 index 0000000..b3aad6a --- /dev/null +++ b/patches/termux-x11/0011-lorie-perfetto-slices.patch @@ -0,0 +1,78 @@ +lorie: trace draw, fence and swap latency with Perfetto + +Frame counters cannot identify whether rendering, fence waits, or swapping +caused a stall. Add inactive-by-default ATrace slices rather than per-frame log +output. + +--- a/renderer.c ++++ b/renderer.c +@@ -16,6 +16,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -30,6 +31,18 @@ + #define log(...) __android_log_print(ANDROID_LOG_DEBUG, "gles-renderer", __VA_ARGS__) + #define loge(...) __android_log_print(ANDROID_LOG_ERROR, "gles-renderer", __VA_ARGS__) + ++static inline bool traceBegin(const char *name) { ++ bool enabled = ATrace_isEnabled(); ++ if (enabled) ++ ATrace_beginSection(name); ++ return enabled; ++} ++ ++static inline void traceEnd(bool enabled) { ++ if (enabled) ++ ATrace_endSection(); ++} ++ + static GLuint createProgram(const char* p_vertex_source, const char* p_fragment_source); + + static int printEglError(char* msg, int line) { +@@ -890,6 +903,7 @@ void rendererRedrawLocked(bool* waitingForBuffers) { + sourceLeft, sourceTop, logicalSourceWidth, logicalSourceHeight); + + // We should signal X server to not use root window while we actively copy it ++ bool frameTrace = traceBegin("uDroid/Lorie draw"); + lorie_mutex_lock(&state->lock, &state->lockingPid); + // Share this draw's flush+fence below instead of a separate round trip per frame. + uint64_t gpuCopySerial = rendererApplyPendingGpuCopiesLocked(); +@@ -920,7 +934,9 @@ void rendererRedrawLocked(bool* waitingForBuffers) { + glFlush(); + + // Wait until root window drawing is finished before giving control back to X server ++ bool rootFenceTrace = traceBegin("uDroid/Lorie root fence wait"); + eglClientWaitSyncKHR(egl_display, fence, 0, EGL_FOREVER); ++ traceEnd(rootFenceTrace); + eglDestroySyncKHR(egl_display, fence); + if (gpuCopySerial) { + state->gpuCopyQueue.completedSerial = gpuCopySerial; +@@ -929,8 +945,10 @@ void rendererRedrawLocked(bool* waitingForBuffers) { + state->waitForNextFrame = true; + lorie_mutex_unlock(&state->lock, &state->lockingPid); + ++ bool swapTrace = traceBegin("uDroid/Lorie eglSwapBuffers"); + if (eglSwapBuffers(egl_display, sfc) != EGL_TRUE) + printEglError("Failed to swap buffers", __LINE__); ++ traceEnd(swapTrace); + + // Perform a little drawing operation to make sure the next buffer is ready on the next invocation of drawing + glEnable(GL_SCISSOR_TEST); +@@ -939,10 +957,13 @@ void rendererRedrawLocked(bool* waitingForBuffers) { + glClear(GL_COLOR_BUFFER_BIT); + glDisable(GL_SCISSOR_TEST); + fence = eglCreateSyncKHR(egl_display, EGL_SYNC_FENCE_KHR, NULL); ++ bool postSwapFenceTrace = traceBegin("uDroid/Lorie post-swap fence wait"); + eglClientWaitSyncKHR(egl_display, fence, EGL_SYNC_FLUSH_COMMANDS_BIT_KHR, EGL_FOREVER); ++ traceEnd(postSwapFenceTrace); + eglDestroySyncKHR(egl_display, fence); + + state->renderedFrames++; ++ traceEnd(frameTrace); + } + + static inline __always_inline bool rendererShouldWait(bool *waitingForBuffers) { diff --git a/patches/termux-x11/0015-export-pixmaps-through-dri3.patch b/patches/termux-x11/0015-export-pixmaps-through-dri3.patch new file mode 100644 index 0000000..030b887 --- /dev/null +++ b/patches/termux-x11/0015-export-pixmaps-through-dri3.patch @@ -0,0 +1,215 @@ +lorie: export redirected pixmaps through DRI3 + +Implement fds_from_pixmap with aligned, CPU-mappable DMA-heap storage so +compositors can share redirected pixmaps with Mesa instead of falling back to +XGetImage readback. + +diff --git a/InitOutput.c b/InitOutput.c +index 2c7453f..eb1889f 100644 +--- a/InitOutput.c ++++ b/InitOutput.c +@@ -1299,6 +1299,27 @@ static int lorieGetFormats(__unused ScreenPtr screen, CARD32 *num_formats, CARD3 + return TRUE; + } + ++static int lorieFdsFromPixmap(__unused ScreenPtr screen, PixmapPtr pixmap, ++ int *fds, uint32_t *strides, ++ uint32_t *offsets, uint64_t *modifier) { ++ LorieBuffer *buffer = lorieEnsureGpuSampleable(pixmap, LORIEBUFFER_FD); ++ uint32_t size; ++ if (!buffer) ++ return 0; ++ ++ const LorieBuffer_Desc *desc = LorieBuffer_description(buffer); ++ fds[0] = LorieBuffer_exportDmaBuf(buffer, &offsets[0], &size); ++ if (fds[0] < 0) ++ return 0; ++ ++ strides[0] = desc->stride * 4; ++ *modifier = DRM_FORMAT_MOD_LINEAR; ++ if (lorieServerDebugEnabled) ++ log(INFO, "DRI3: exported DMA-BUF pixmap %ux%u stride %u size %u", ++ pixmap->drawable.width, pixmap->drawable.height, strides[0], size); ++ return 1; ++} ++ + static uint64_t lorieSupportedModifiers[] = { + 1255, /* AHardwareBuffer BGRA socket transport */ + 1257, /* AHardwareBuffer RGBA socket transport */ +@@ -1348,7 +1370,7 @@ static int lorieGetDrawableModifiers(__unused DrawablePtr draw, uint32_t format, + + static dri3_screen_info_rec lorieDri3Info = { + .version = 2, +- .fds_from_pixmap = FalseNoop, ++ .fds_from_pixmap = lorieFdsFromPixmap, + .pixmap_from_fds = loriePixmapFromFds, + .get_formats = lorieGetFormats, + .get_modifiers = lorieGetModifiers, +diff --git a/buffer.c b/buffer.c +index f063c79..8ee07db 100644 +--- a/buffer.c ++++ b/buffer.c +@@ -12,6 +12,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -142,6 +143,49 @@ static inline size_t alignToPage(size_t size) { + return (size + page_size - 1) & ~(page_size - 1); + } + ++static inline int32_t alignGpuSampledStride(int32_t width) { ++ const int32_t pixelsPerAlignment = 64 / sizeof(uint32_t); ++ return (width + pixelsPerAlignment - 1) & ~(pixelsPerAlignment - 1); ++} ++ ++static int createDmaHeapRegion(size_t size) { ++ static const char *const heapPaths[] = { ++ "/dev/dma_heap/system-uncached", ++ "/dev/dma_heap/system", ++ }; ++ struct dma_heap_allocation_data allocation = { ++ .len = alignToPage(size), ++ .fd_flags = O_RDWR | O_CLOEXEC, ++ }; ++ ++ for (size_t i = 0; i < sizeof(heapPaths) / sizeof(heapPaths[0]); i++) { ++ int heap = open(heapPaths[i], O_RDONLY | O_CLOEXEC); ++ if (heap < 0) ++ continue; ++ ++ int result; ++ do { ++ result = ioctl(heap, DMA_HEAP_IOCTL_ALLOC, &allocation); ++ } while (result < 0 && errno == EINTR); ++ int savedErrno = errno; ++ close(heap); ++ errno = savedErrno; ++ if (result == 0) ++ return (int) allocation.fd; ++ } ++ ++ return -1; ++} ++ ++static int syncDmaBuf(int fd, uint64_t flags) { ++ struct dma_buf_sync sync = { .flags = flags }; ++ int result; ++ do { ++ result = ioctl(fd, DMA_BUF_IOCTL_SYNC, &sync); ++ } while (result < 0 && errno == EINTR); ++ return result; ++} ++ + #pragma clang diagnostic push + #pragma ide diagnostic ignored "UnreachableCallsOfFunction" + int LorieBuffer_createRegion(char const* name, size_t size) { +@@ -266,12 +308,16 @@ static LorieBuffer* allocate(int32_t width, int32_t stride, int32_t height, int8 + + __LIBC_HIDDEN__ LorieBuffer* LorieBuffer_allocate(int32_t width, int32_t height, int8_t format, int8_t type) { + int fd = -1; ++ int32_t stride = width; + size_t size = 0; + AHardwareBuffer *ahardwarebuffer = NULL; + + if (type == LORIEBUFFER_FD) { +- size = alignToPage(width * height * sizeof(uint32_t)); +- fd = LorieBuffer_createRegion("LorieBuffer", size); ++ stride = alignGpuSampledStride(width); ++ size = alignToPage(stride * height * sizeof(uint32_t)); ++ fd = createDmaHeapRegion(size); ++ if (fd < 0) ++ fd = LorieBuffer_createRegion("LorieBuffer", size); + if (fd < 0) + return NULL; + } else if (type == LORIEBUFFER_AHARDWAREBUFFER) { +@@ -281,7 +327,7 @@ __LIBC_HIDDEN__ LorieBuffer* LorieBuffer_allocate(int32_t width, int32_t height, + dprintf(2, "FATAL: failed to allocate AHardwareBuffer (width %d height %d format %d): error %d\n", width, height, format, err); + } + +- return allocate(width, width, height, format, type, ahardwarebuffer, fd, size, 0, true); ++ return allocate(width, stride, height, format, type, ahardwarebuffer, fd, size, 0, true); + } + + __LIBC_HIDDEN__ LorieBuffer* LorieBuffer_wrapFileDescriptor(int32_t width, int32_t stride, int32_t height, int8_t format, int fd, off_t offset) { +@@ -299,8 +345,12 @@ __LIBC_HIDDEN__ void LorieBuffer_convert(LorieBuffer* buffer, int8_t type, int8_ + return; + + if (type == LORIEBUFFER_FD) { +- size_t size = alignToPage(buffer->desc.stride * buffer->desc.height * sizeof(uint32_t)); +- int fd = LorieBuffer_createRegion("LorieBuffer", size); ++ const int32_t sourceStride = buffer->desc.stride; ++ const int32_t destinationStride = alignGpuSampledStride(buffer->desc.width); ++ size_t size = alignToPage(destinationStride * buffer->desc.height * sizeof(uint32_t)); ++ int fd = createDmaHeapRegion(size); ++ if (fd < 0) ++ fd = LorieBuffer_createRegion("LorieBuffer", size); + if (fd < 0) + return; + +@@ -311,10 +361,18 @@ __LIBC_HIDDEN__ void LorieBuffer_convert(LorieBuffer* buffer, int8_t type, int8_ + return; + } + +- pixman_blt(buffer->desc.data, data, buffer->desc.stride, buffer->desc.stride, 32, 32, 0, 0, 0, 0, buffer->desc.width, buffer->desc.height); ++ bool isDmaBuf = syncDmaBuf(fd, DMA_BUF_SYNC_START | DMA_BUF_SYNC_WRITE) == 0; ++ pixman_blt(buffer->desc.data, data, sourceStride, destinationStride, 32, 32, ++ 0, 0, 0, 0, buffer->desc.width, buffer->desc.height); ++ if (isDmaBuf && syncDmaBuf(fd, DMA_BUF_SYNC_END | DMA_BUF_SYNC_WRITE) < 0) { ++ munmap(data, size); ++ close(fd); ++ return; ++ } + + buffer->desc.type = type; + buffer->desc.format = format; ++ buffer->desc.stride = destinationStride; + buffer->fd = fd; + buffer->size = size; + buffer->offset = 0; +@@ -383,6 +441,27 @@ __LIBC_HIDDEN__ const LorieBuffer_Desc* LorieBuffer_description(LorieBuffer* buf + return buffer ? &buffer->desc : &none; + } + ++__LIBC_HIDDEN__ int LorieBuffer_exportDmaBuf(LorieBuffer* buffer, ++ uint32_t* offset, ++ uint32_t* size) { ++ if (!buffer || !offset || !size || buffer->desc.type != LORIEBUFFER_FD || ++ buffer->fd < 0 || buffer->offset < 0 || buffer->offset > UINT32_MAX || ++ buffer->size > UINT32_MAX) ++ return -1; ++ ++ if (syncDmaBuf(buffer->fd, DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ) < 0) ++ return -1; ++ if (syncDmaBuf(buffer->fd, DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ) < 0) ++ return -1; ++ ++ int fd = fcntl(buffer->fd, F_DUPFD_CLOEXEC, 0); ++ if (fd >= 0) { ++ *offset = (uint32_t) buffer->offset; ++ *size = (uint32_t) buffer->size; ++ } ++ return fd; ++} ++ + __LIBC_HIDDEN__ int LorieBuffer_lock(LorieBuffer* buffer, void** out) { + int ret = 0; + if (!buffer) +diff --git a/buffer.h b/buffer.h +index 751004b..98ac68b 100644 +--- a/buffer.h ++++ b/buffer.h +@@ -112,6 +112,10 @@ STATIC_INLINE void LorieBuffer_release(LorieBuffer* _Nullable buffer) { + */ + const LorieBuffer_Desc* _Nonnull LorieBuffer_description(LorieBuffer* _Nullable buffer); + ++int LorieBuffer_exportDmaBuf(LorieBuffer* _Nullable buffer, ++ uint32_t* _Nonnull offset, ++ uint32_t* _Nonnull size); ++ + /** + * Lock the AHardwareBuffer for direct CPU access. + * See AHardwareBuffer_lock() description for details diff --git a/patches/termux-x11/0016-accept-fd-gpu-copy-sources.patch b/patches/termux-x11/0016-accept-fd-gpu-copy-sources.patch new file mode 100644 index 0000000..f144226 --- /dev/null +++ b/patches/termux-x11/0016-accept-fd-gpu-copy-sources.patch @@ -0,0 +1,32 @@ +lorie: accept DMA-BUF pixmaps as GPU-copy sources + +The renderer accepts both DMA-BUF and AHardwareBuffer storage. Avoid converting +an already usable Venus or Zink pixmap and forcing Present through the CPU +fallback. + +--- a/InitOutput.c ++++ b/InitOutput.c +@@ -135,6 +135,19 @@ static LorieBuffer *lorieEnsureGpuSampleable(PixmapPtr pixmap, int8_t type) { + return desc->type == type ? priv->buffer : NULL; + } + ++static LorieBuffer *lorieGpuCopySource(PixmapPtr pixmap) { ++ LoriePixmapPriv *priv = LORIE_PIXMAP_PRIV_FROM_PIXMAP(pixmap); ++ const LorieBuffer_Desc *desc; ++ if (!priv || !priv->buffer || priv->mem) ++ return NULL; ++ ++ desc = LorieBuffer_description(priv->buffer); ++ if (desc->type == LORIEBUFFER_FD || desc->type == LORIEBUFFER_AHARDWAREBUFFER) ++ return priv->buffer; ++ ++ return lorieEnsureGpuSampleable(pixmap, LORIEBUFFER_AHARDWAREBUFFER); ++} ++ + static Bool lorieServerDebugEnabled = FALSE; + + void OsVendorInit(void) { +@@ -921,2 +934,2 @@ Bool lorieTryScheduleGpuCopy(PixmapPtr pixmap, PixmapPtr dst, RegionPtr update, +- if (!(srcBuffer = lorieEnsureGpuSampleable(pixmap, LORIEBUFFER_AHARDWAREBUFFER)) || ++ if (!(srcBuffer = lorieGpuCopySource(pixmap)) || + !(dstBuffer = lorieEnsureGpuSampleable(dst, LORIEBUFFER_AHARDWAREBUFFER))) { diff --git a/patches/termux-x11/0017-export-ahardwarebuffer-pixmaps-through-dri3.patch b/patches/termux-x11/0017-export-ahardwarebuffer-pixmaps-through-dri3.patch new file mode 100644 index 0000000..f7555bf --- /dev/null +++ b/patches/termux-x11/0017-export-ahardwarebuffer-pixmaps-through-dri3.patch @@ -0,0 +1,176 @@ +lorie: export existing AHardwareBuffers through DRI3 + +Redirected compositor pixmaps may already have GPU-native storage. Duplicate +the underlying DMA-BUF while preserving offset and size instead of replacing +the buffer and forcing a readback. + +--- a/InitOutput.c ++++ b/InitOutput.c +@@ -1333,12 +1333,28 @@ + static int lorieFdsFromPixmap(__unused ScreenPtr screen, PixmapPtr pixmap, + int *fds, uint32_t *strides, + uint32_t *offsets, uint64_t *modifier) { +- LorieBuffer *buffer = lorieEnsureGpuSampleable(pixmap, LORIEBUFFER_FD); ++ LoriePixmapPriv *priv = LORIE_PIXMAP_PRIV_FROM_PIXMAP(pixmap); ++ LorieBuffer *buffer; + uint32_t size; +- if (!buffer) ++ if (!priv || !priv->buffer || priv->mem) + return 0; + ++ /* ++ * Redirected compositor pixmaps are already AHB-backed. Export that same ++ * storage through DRI3; rejecting it forces Mesa into an XGetImage readback. ++ */ ++ buffer = priv->buffer; + const LorieBuffer_Desc *desc = LorieBuffer_description(buffer); ++ if (desc->type == LORIEBUFFER_REGULAR) { ++ buffer = lorieEnsureGpuSampleable(pixmap, LORIEBUFFER_FD); ++ if (!buffer) ++ return 0; ++ desc = LorieBuffer_description(buffer); ++ } ++ if (desc->type != LORIEBUFFER_FD && ++ desc->type != LORIEBUFFER_AHARDWAREBUFFER) ++ return 0; ++ + fds[0] = LorieBuffer_exportDmaBuf(buffer, &offsets[0], &size); + if (fds[0] < 0) + return 0; +--- a/buffer.c ++++ b/buffer.c +@@ -17,6 +17,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -441,12 +441,115 @@ + static const LorieBuffer_Desc none = {0}; + return buffer ? &buffer->desc : &none; + } ++ ++/* ++ * AHardwareBuffer exposes no public direct fd getter. Round-trip the native ++ * handle through the NDK socket API, then accept only a CPU-readable DMA-BUF. ++ */ ++static int exportAHardwareBufferDmaBuf(AHardwareBuffer *buffer, ++ uint32_t minimumSize, ++ uint32_t *size) { ++ enum { MAX_HANDLE_FDS = 16 }; ++ int sockets[2] = {-1, -1}; ++ int receivedFds[MAX_HANDLE_FDS]; ++ size_t receivedFdCount = 0; ++ int selectedFd = -1; ++ ssize_t receivedBytes; ++ char payload[4096]; ++ char control[CMSG_SPACE(sizeof(int) * MAX_HANDLE_FDS)]; ++ struct iovec iov = { ++ .iov_base = payload, ++ .iov_len = sizeof(payload), ++ }; ++ struct msghdr message = { ++ .msg_iov = &iov, ++ .msg_iovlen = 1, ++ .msg_control = control, ++ .msg_controllen = sizeof(control), ++ }; ++ ++ if (!buffer || socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sockets) != 0) ++ return -1; ++ ++ if (AHardwareBuffer_sendHandleToUnixSocket(buffer, sockets[0]) != 0) ++ goto out; ++ close(sockets[0]); ++ sockets[0] = -1; + ++ receivedBytes = recvmsg(sockets[1], &message, MSG_CMSG_CLOEXEC); ++ if (receivedBytes <= 0) ++ goto out; ++ ++ for (struct cmsghdr *header = CMSG_FIRSTHDR(&message); ++ header && receivedFdCount < MAX_HANDLE_FDS; ++ header = CMSG_NXTHDR(&message, header)) { ++ if (header->cmsg_level != SOL_SOCKET || header->cmsg_type != SCM_RIGHTS) ++ continue; ++ ++ size_t count = (header->cmsg_len - CMSG_LEN(0)) / sizeof(int); ++ int *fds = (int *)CMSG_DATA(header); ++ for (size_t i = 0; i < count && receivedFdCount < MAX_HANDLE_FDS; i++) ++ receivedFds[receivedFdCount++] = fds[i]; ++ } ++ ++ /* recvmsg may still install the descriptors that fit before reporting a ++ * truncated control message. Close those descriptors through out rather ++ * than attempting to interpret an incomplete native handle. */ ++ if (message.msg_flags & MSG_CTRUNC) ++ goto out; ++ ++ for (size_t i = 0; i < receivedFdCount; i++) { ++ struct stat info = {0}; ++ int fd = receivedFds[i]; ++ ++ if (fstat(fd, &info) != 0 || info.st_size < minimumSize || ++ info.st_size > UINT32_MAX) ++ continue; ++ if (syncDmaBuf(fd, DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ) != 0) ++ continue; ++ if (syncDmaBuf(fd, DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ) != 0) ++ continue; ++ ++ selectedFd = fd; ++ *size = (uint32_t)info.st_size; ++ receivedFds[i] = -1; ++ break; ++ } ++ ++out: ++ for (size_t i = 0; i < receivedFdCount; i++) ++ if (receivedFds[i] >= 0) ++ close(receivedFds[i]); ++ if (sockets[0] >= 0) ++ close(sockets[0]); ++ if (sockets[1] >= 0) ++ close(sockets[1]); ++ return selectedFd; ++} ++ + __LIBC_HIDDEN__ int LorieBuffer_exportDmaBuf(LorieBuffer* buffer, + uint32_t* offset, + uint32_t* size) { +- if (!buffer || !offset || !size || buffer->desc.type != LORIEBUFFER_FD || +- buffer->fd < 0 || buffer->offset < 0 || buffer->offset > UINT32_MAX || ++ int fd; ++ ++ if (!buffer || !offset || !size) ++ return -1; ++ ++ if (buffer->desc.type == LORIEBUFFER_AHARDWAREBUFFER) { ++ uint64_t minimumSize = (uint64_t)buffer->desc.stride * ++ buffer->desc.height * sizeof(uint32_t); ++ if (minimumSize > UINT32_MAX) ++ return -1; ++ ++ fd = exportAHardwareBufferDmaBuf(buffer->desc.buffer, ++ (uint32_t)minimumSize, size); ++ if (fd >= 0) ++ *offset = 0; ++ return fd; ++ } ++ ++ if (buffer->desc.type != LORIEBUFFER_FD || buffer->fd < 0 || ++ buffer->offset < 0 || buffer->offset > UINT32_MAX || + buffer->size > UINT32_MAX) + return -1; + +@@ -455,7 +554,7 @@ + if (syncDmaBuf(buffer->fd, DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ) < 0) + return -1; + +- int fd = fcntl(buffer->fd, F_DUPFD_CLOEXEC, 0); ++ fd = fcntl(buffer->fd, F_DUPFD_CLOEXEC, 0); + if (fd >= 0) { + *offset = (uint32_t) buffer->offset; + *size = (uint32_t) buffer->size; diff --git a/patches/termux-x11/0018-present-damage-ordering.patch b/patches/termux-x11/0018-present-damage-ordering.patch new file mode 100644 index 0000000..8a4274c --- /dev/null +++ b/patches/termux-x11/0018-present-damage-ordering.patch @@ -0,0 +1,198 @@ +present: order damage and ownership around async GPU copies + +Keep damage pending until the queued copy completes, drain earlier GPU work +before a CPU fallback, and translate damage into screen coordinates. Increase +the bounded damage list to 64 rectangles and use acquire/release ordering for +completion state. + +Adapted from termux/termux-x11#1148 commits 0995bdc, e32828e, bac0460, +4eb76e3, and 1346e1e. + +--- a/lorie/lorie.h ++++ b/lorie/lorie.h +@@ -177,7 +177,7 @@ + + typedef struct { int16_t x1, y1, x2, y2; } LorieGpuCopyRect; + +-#define LORIE_GPU_COPY_MAX_RECTS 16 ++#define LORIE_GPU_COPY_MAX_RECTS 64 + #define LORIE_GPU_COPY_QUEUE_CAPACITY 8 + + typedef struct { +--- a/lorie/InitOutput.c ++++ b/lorie/InitOutput.c +@@ -152,7 +152,7 @@ + + typedef struct { + LorieBuffer *buffer; +- bool flipped, wasLocked, imported; ++ bool flipped, wasLocked, imported, gpuOnly; + void *locked; + void *mem; + } LoriePixmapPriv; +@@ -1025,20 +1025,21 @@ + } + + Bool lorieGpuCopyIsDone(uint64_t serial) { +- return pvfb->state->gpuCopyQueue.completedSerial >= serial; ++ return __atomic_load_n(&pvfb->state->gpuCopyQueue.completedSerial, __ATOMIC_ACQUIRE) >= serial; + } + +-Bool loriePixmapRequiresGpuCopy(PixmapPtr pixmap) { +- LoriePixmapPriv *priv = pixmap ? LORIE_PIXMAP_PRIV_FROM_PIXMAP(pixmap) : NULL; +- const LorieBuffer_Desc *desc = priv ? LorieBuffer_description(priv->buffer) : NULL; ++void lorieGpuCopyFlush(void) { ++ uint64_t serial = pvfb->gpuCopySerialCounter; ++ for (int i = 0; i < 500 && !lorieGpuCopyIsDone(serial); i++) { ++ if (!lorieConnectionAlive()) ++ return; ++ usleep(200); ++ } ++} + +- /* A DRI3 AHardwareBuffer may be allocated without CPU read/write usage. In +- * particular, gfxstream scanout images are valid GPU textures but Android +- * gralloc rejects AHardwareBuffer_lock() for them. Never let a temporarily +- * detached renderer turn that expected lifecycle state into a CPU-copy +- * fallback; Present will wait for the renderer to reconnect instead. */ +- return priv && priv->imported && desc && +- desc->type == LORIEBUFFER_AHARDWAREBUFFER; ++Bool loriePixmapIsGpuOnly(PixmapPtr pixmap) { ++ LoriePixmapPriv *priv = pixmap ? LORIE_PIXMAP_PRIV_FROM_PIXMAP(pixmap) : NULL; ++ return priv && priv->gpuOnly; + } + + void lorieGpuCopyAck(PixmapPtr pixmap, void *dst_buffer) { +@@ -1258,9 +1259,13 @@ + if (modifier == AHARDWAREBUFFER_RGBA_SOCKET_FD) + LorieBuffer_setNeedsBgraSwizzle(priv->buffer, true); + ++ void *probe = NULL; ++ priv->gpuOnly = LorieBuffer_lock(priv->buffer, &probe) != 0; ++ LorieBuffer_unlock(priv->buffer); ++ + screen->ModifyPixmapHeader(pixmap, desc.width, desc.height, 0, 0, desc.stride * 4, NULL); + if (lorieServerDebugEnabled) +- log(INFO, "DRI3: imported AHardwareBuffer, modifier %llu, %ux%u stride %u", (unsigned long long) modifier, desc.width, desc.height, desc.stride); ++ log(INFO, "DRI3: imported AHardwareBuffer, modifier %llu, %ux%u stride %u%s", (unsigned long long) modifier, desc.width, desc.height, desc.stride, priv->gpuOnly ? ", not lockable" : ""); + } + + return pixmap; +--- a/xserver/present/present_priv.h ++++ b/xserver/present/present_priv.h +@@ -97,6 +97,7 @@ + Bool gpu_copy_pending; + uint64_t gpu_copy_serial; + void *gpu_copy_dst_buffer; /* opaque LorieBuffer*, held for lorieGpuCopyAck */ ++ RegionPtr gpu_copy_damage; /* reported as damaged once the GPU copy has finished */ + }; + + /* lorie: hooks implemented in lorie/InitOutput.c, used by present_execute_copy() to offload the +@@ -106,7 +107,9 @@ + uint64_t *out_serial, void **out_dst_buffer); + extern Bool lorieGpuCopyIsDone(uint64_t serial); + extern void lorieGpuCopyAck(PixmapPtr pixmap, void *dst_buffer); +-extern Bool loriePixmapRequiresGpuCopy(PixmapPtr pixmap); ++/* Wait for queued GPU copies before a later CPU copy can overwrite their destination. */ ++extern void lorieGpuCopyFlush(void); ++extern Bool loriePixmapIsGpuOnly(PixmapPtr pixmap); + /* lorie: implemented in lorie/cmdentrypoint.c. Avoids waiting forever on a GPU copy that a dead renderer will never finish. */ + extern bool lorieConnectionAlive(void); + +--- a/xserver/present/present_execute.c ++++ b/xserver/present/present_execute.c +@@ -96,16 +96,28 @@ + if (vblank->gpu_copy_pending) { + lorieGpuCopyAck(vblank->pixmap, vblank->gpu_copy_dst_buffer); + vblank->gpu_copy_pending = FALSE; ++ if (vblank->gpu_copy_damage) { ++ DamageDamageRegion(&window->drawable, vblank->gpu_copy_damage); ++ RegionDestroy(vblank->gpu_copy_damage); ++ vblank->gpu_copy_damage = NULL; ++ } + } else if (lorieTryScheduleGpuCopy(vblank->pixmap, gpuCopyDst, vblank->update, gpuCopyXOff, gpuCopyYOff, + &vblank->gpu_copy_serial, &vblank->gpu_copy_dst_buffer)) { + /* lorie: our GPU blit writes the destination pixmap directly, bypassing the normal GC + * ops that Damage tracking hooks into, so report it manually - same drawable and region + * present_scmd.c's flip success path already uses, so this also reaches Composite's +- * per-window damage for redirected windows, not just lorie's own root damage. */ +- RegionPtr damage = vblank->update ? vblank->update : &window->clipList; +- if (vblank->update) +- RegionIntersect(damage, damage, &window->clipList); +- DamageDamageRegion(&window->drawable, damage); ++ * per-window damage for redirected windows, not just lorie's own root damage. ++ * vblank->update is in pixmap coordinates, damage and clipList are in screen coordinates. */ ++ RegionPtr damage = RegionDuplicate(vblank->update ? vblank->update : &window->clipList); ++ if (damage) { ++ if (vblank->update) { ++ RegionTranslate(damage, vblank->x_off + window->drawable.x, vblank->y_off + window->drawable.y); ++ RegionIntersect(damage, damage, &window->clipList); ++ } ++ DamageDamageRegion(&window->drawable, damage); ++ /* A compositor may read the destination before the GPU copy completes. */ ++ vblank->gpu_copy_damage = damage; ++ } + + /* lorie: copy offloaded to the renderer's GPU context. The region was already + * consumed (copied into the shared command queue), so free it like +@@ -122,17 +134,25 @@ + /* Failed to requeue for polling (e.g. OOM) - release our extra ref and treat the + * presumably still in-flight GPU copy as done; same best-effort fallback as above. */ + lorieGpuCopyAck(vblank->pixmap, vblank->gpu_copy_dst_buffer); +- } else if (loriePixmapRequiresGpuCopy(vblank->pixmap)) { ++ if (vblank->gpu_copy_damage) { ++ RegionDestroy(vblank->gpu_copy_damage); ++ vblank->gpu_copy_damage = NULL; ++ } ++ } else if (loriePixmapIsGpuOnly(vblank->pixmap)) { + /* Imported AHardwareBuffers are not guaranteed to be CPU-mappable. If + * the Android renderer is detached or its queue is temporarily full, + * preserve the Present request until a later fake-vblank instead of + * entering EXA's CPU path and fatally failing PrepareAccess. */ + if (Success == screen_priv->queue_vblank(screen, window, vblank->crtc, + vblank->event_id, crtc_msc + 1)) { + vblank->queued = TRUE; + return; + } ++ present_vblank_scrap(vblank); ++ screen_priv->flush(window); ++ return; + } else { ++ lorieGpuCopyFlush(); + present_copy_region(&window->drawable, vblank->pixmap, vblank->update, vblank->x_off, vblank->y_off); + + /* present_copy_region sticks the region into a scratch GC, +--- a/xserver/present/present_vblank.c ++++ b/xserver/present/present_vblank.c +@@ -188,6 +188,15 @@ + vblank->pixmap->drawable.id, vblank->window->drawable.id, + vblank->crtc)); + ++ if (vblank->gpu_copy_pending) { ++ lorieGpuCopyAck(vblank->pixmap, vblank->gpu_copy_dst_buffer); ++ vblank->gpu_copy_pending = FALSE; ++ if (vblank->gpu_copy_damage) { ++ RegionDestroy(vblank->gpu_copy_damage); ++ vblank->gpu_copy_damage = NULL; ++ } ++ } ++ + present_pixmap_idle(vblank->pixmap, vblank->window, vblank->serial, vblank->idle_fence); + present_fence_destroy(vblank->idle_fence); + dixDestroyPixmap(vblank->pixmap, vblank->pixmap->drawable.id); +@@ -200,6 +209,15 @@ + void + present_vblank_destroy(present_vblank_ptr vblank) + { ++ if (vblank->gpu_copy_pending) { ++ lorieGpuCopyAck(vblank->pixmap, vblank->gpu_copy_dst_buffer); ++ vblank->gpu_copy_pending = FALSE; ++ if (vblank->gpu_copy_damage) { ++ RegionDestroy(vblank->gpu_copy_damage); ++ vblank->gpu_copy_damage = NULL; ++ } ++ } ++ + /* Remove vblank from window and screen lists */ + xorg_list_del(&vblank->window_list); + /* Also make sure vblank is removed from event queue (wnmd) */ diff --git a/patches/termux-x11/0019-dmabuf-exa-cpu-access-sync.patch b/patches/termux-x11/0019-dmabuf-exa-cpu-access-sync.patch new file mode 100644 index 0000000..48e5e78 --- /dev/null +++ b/patches/termux-x11/0019-dmabuf-exa-cpu-access-sync.patch @@ -0,0 +1,90 @@ +exa: synchronize CPU access to DMA-BUF pixmaps + +DRI3 clients may render with the GPU while Xorg reads or writes the same mapped +storage. Emit matching DMA_BUF_SYNC_START and END ioctls around the outermost +EXA access to prevent partially updated rows. + +Based on termux/termux-x11@d85d65c7809c53c1707cb771fa8e4e645cc9f3ea. + +--- a/buffer.h ++++ b/buffer.h +@@ -115,6 +115,8 @@ const LorieBuffer_Desc* _Nonnull LorieBuffer_description(LorieBuffer* _Nullable + int LorieBuffer_exportDmaBuf(LorieBuffer* _Nullable buffer, + uint32_t* _Nonnull offset, + uint32_t* _Nonnull size); ++bool LorieBuffer_cpuAccessBegin(LorieBuffer* _Nullable buffer, bool write); ++void LorieBuffer_cpuAccessEnd(LorieBuffer* _Nullable buffer, bool write); + + /** + * Lock the AHardwareBuffer for direct CPU access. +--- a/buffer.c ++++ b/buffer.c +@@ -548,6 +548,30 @@ __LIBC_HIDDEN__ int LorieBuffer_exportDmaBuf(LorieBuffer* buffer, + return fd; + } + ++__LIBC_HIDDEN__ bool LorieBuffer_cpuAccessBegin(LorieBuffer* buffer, bool write) { ++ if (!buffer || buffer->desc.type != LORIEBUFFER_FD || ++ buffer->dmaBufSyncSupport == DMA_BUF_SYNC_UNSUPPORTED) ++ return false; ++ ++ if (syncDmaBuf(buffer->fd, DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ | ++ (write ? DMA_BUF_SYNC_WRITE : 0)) == 0) { ++ buffer->dmaBufSyncSupport = DMA_BUF_SYNC_SUPPORTED; ++ return true; ++ } ++ ++ int error = errno; ++ if (buffer->dmaBufSyncSupport == DMA_BUF_SYNC_UNKNOWN && ++ (error == ENOTTY || error == EINVAL || error == ENOSYS)) { ++ buffer->dmaBufSyncSupport = DMA_BUF_SYNC_UNSUPPORTED; ++ } ++ return false; ++} ++ ++__LIBC_HIDDEN__ void LorieBuffer_cpuAccessEnd(LorieBuffer* buffer, bool write) { ++ syncDmaBuf(buffer->fd, DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ | ++ (write ? DMA_BUF_SYNC_WRITE : 0)); ++} ++ + __LIBC_HIDDEN__ int LorieBuffer_lock(LorieBuffer* buffer, void** out) { + int ret = 0; + if (!buffer) +--- a/InitOutput.c ++++ b/InitOutput.c +@@ -112,6 +112,8 @@ static pthread_cond_t* volatile rendererCond = &rendererCondPlaceholder; + typedef struct { + LorieBuffer *buffer; + bool flipped, wasLocked, imported, gpuOnly; ++ uint8_t dmaBufAccessDepth; ++ bool dmaBufAccessWrite; + void *locked; + void *mem; + } LoriePixmapPriv; +@@ -1170,6 +1172,14 @@ Bool loriePrepareAccess(PixmapPtr pPix, int index) { + } else + priv->wasLocked = TRUE; + ++ if (priv->buffer && !priv->mem) { ++ bool write = index == EXA_PREPARE_DEST || index == EXA_PREPARE_AUX_DEST; ++ if (priv->dmaBufAccessDepth || LorieBuffer_cpuAccessBegin(priv->buffer, write)) { ++ priv->dmaBufAccessDepth++; ++ priv->dmaBufAccessWrite |= write; ++ } ++ } ++ + pPix->devPrivate.ptr = priv->locked ?: priv->mem; + return TRUE; + } +@@ -1176,6 +1186,11 @@ void lorieFinishAccess(PixmapPtr pPix, int index) { + if (lorieNeedsGpuLock(pPix, priv, index)) + lorie_mutex_unlock(&pvfb->state->lock, &pvfb->state->lockingPid); + ++ if (priv->dmaBufAccessDepth && !--priv->dmaBufAccessDepth) { ++ LorieBuffer_cpuAccessEnd(priv->buffer, priv->dmaBufAccessWrite); ++ priv->dmaBufAccessWrite = false; ++ } ++ + if (!priv->wasLocked) { + LorieBuffer_unlock(priv->buffer); + priv->locked = NULL; diff --git a/patches/termux-x11/0020-renderer-route-capability-probe.patch b/patches/termux-x11/0020-renderer-route-capability-probe.patch new file mode 100644 index 0000000..8a63ba7 --- /dev/null +++ b/patches/termux-x11/0020-renderer-route-capability-probe.patch @@ -0,0 +1,39 @@ +lorie: report renderer import capabilities at startup + +Emit one structured record with the EGL and GLES extensions needed to identify +the DMA-BUF, AHardwareBuffer, or CPU-upload route. The probe does not alter +route selection. + +--- a/renderer.c ++++ b/renderer.c +@@ -298,6 +298,30 @@ int rendererInitThread(void* cookie) { + eglMakeCurrent(egl_display, sfc, sfc, ctx); + eglSwapInterval(egl_display, 0); + ++ const char *eglVendor = eglQueryString(egl_display, EGL_VENDOR); ++ const char *eglVersion = eglQueryString(egl_display, EGL_VERSION); ++ const char *eglExtensions = eglQueryString(egl_display, EGL_EXTENSIONS); ++ const char *glExtensions = (const char *) glGetString(GL_EXTENSIONS); ++ bool dmaBufImport = eglExtensions && strstr(eglExtensions, "EGL_EXT_image_dma_buf_import"); ++ bool dmaBufModifiers = eglExtensions && strstr(eglExtensions, "EGL_EXT_image_dma_buf_import_modifiers"); ++ bool nativeBuffer = eglExtensions && strstr(eglExtensions, "EGL_ANDROID_image_native_buffer"); ++ bool nativeClientBuffer = eglExtensions && strstr(eglExtensions, "EGL_ANDROID_get_native_client_buffer"); ++ bool nativeFence = eglExtensions && strstr(eglExtensions, "EGL_ANDROID_native_fence_sync"); ++ bool waitSync = eglExtensions && strstr(eglExtensions, "EGL_KHR_wait_sync"); ++ bool imageBase = eglExtensions && strstr(eglExtensions, "EGL_KHR_image_base"); ++ bool externalImage = glExtensions && strstr(glExtensions, "GL_OES_EGL_image_external"); ++ bool ahardwareBufferImport = nativeBuffer && nativeClientBuffer && imageBase && externalImage; ++ const char *route = dmaBufImport && imageBase ? "egl-dmabuf" : ++ ahardwareBufferImport ? "ahardwarebuffer" : "cpu-upload"; ++ const char *fallback = dmaBufImport && imageBase ? "none" : ++ ahardwareBufferImport ? "direct DMA-BUF import unavailable" : ++ "no GPU-native import route"; ++ ++ log("Xlorie: G0 graphics contract vendor=\"%s\" version=\"%s\" dma_buf_import=%d dma_buf_modifiers=%d native_buffer=%d native_client_buffer=%d native_fence=%d wait_sync=%d image_base=%d external_image=%d eligible_route=%s fallback=\"%s\"", ++ eglVendor ?: "unknown", eglVersion ?: "unknown", dmaBufImport, dmaBufModifiers, ++ nativeBuffer, nativeClientBuffer, nativeFence, waitSync, imageBase, externalImage, ++ route, fallback); ++ + g_texture_program = createProgram(vertexShaderSrc, fragmentShaderSrc); + if (!g_texture_program) + log("Xlorie: GLESv2: Unable to create shader program.\n"); diff --git a/patches/termux-x11/0021-preserve-logical-ahb-pixmap-size.patch b/patches/termux-x11/0021-preserve-logical-ahb-pixmap-size.patch new file mode 100644 index 0000000..00c402e --- /dev/null +++ b/patches/termux-x11/0021-preserve-logical-ahb-pixmap-size.patch @@ -0,0 +1,32 @@ +lorie: preserve logical pixmap dimensions for padded AHBs + +Android allocators may enlarge the physical allocation or stride. Validate that +storage covers the requested image, but keep the DRI3 dimensions as the logical +X pixmap size so padding is never exposed as content. + +--- a/InitOutput.c ++++ b/InitOutput.c +@@ -1268,6 +1268,9 @@ static PixmapPtr loriePixmapFromFds(ScreenPtr screen, CARD8 num_fds, const int + && desc.format != AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM + && desc.format != AHARDWAREBUFFER_FORMAT_B8G8R8A8_UNORM, + "DRI3: AHARDWAREBUFFER_SOCKET_FD: wrong format of AHardwareBuffer. Must be one of: AHARDWAREBUFFER_FORMAT_R8G8B8X8_UNORM, AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM, AHARDWAREBUFFER_FORMAT_B8G8R8A8_UNORM (stands for 5)."); ++ check(desc.width < width || desc.height < height, ++ "DRI3: AHardwareBuffer %ux%u is smaller than pixmap %ux%u", ++ desc.width, desc.height, width, height); + check(!(priv->buffer = LorieBuffer_wrapAHardwareBuffer(received_buffer)), "DRI3: LorieBuffer_wrapAHardwareBuffer failed."); + received_buffer = NULL; + if (modifier == AHARDWAREBUFFER_RGBA_SOCKET_FD) +@@ -1278,9 +1281,11 @@ static PixmapPtr loriePixmapFromFds(ScreenPtr screen, CARD8 num_fds, const int + priv->gpuOnly = LorieBuffer_lock(priv->buffer, &probe) != 0; + LorieBuffer_unlock(priv->buffer); + +- screen->ModifyPixmapHeader(pixmap, desc.width, desc.height, 0, 0, desc.stride * 4, NULL); ++ /* The AHB may contain physical padding hidden from the X pixmap. */ ++ screen->ModifyPixmapHeader(pixmap, width, height, 0, 0, desc.stride * 4, NULL); + if (lorieServerDebugEnabled) +- log(INFO, "DRI3: imported AHardwareBuffer, modifier %llu, %ux%u stride %u%s", (unsigned long long) modifier, desc.width, desc.height, desc.stride, priv->gpuOnly ? ", not lockable" : ""); ++ log(INFO, "DRI3: imported AHardwareBuffer, modifier %llu, logical %ux%u physical %ux%u stride %u%s", ++ (unsigned long long) modifier, width, height, desc.width, desc.height, desc.stride, priv->gpuOnly ? ", not lockable" : ""); + } + + return pixmap; diff --git a/patches/termux-x11/0022-disable-incoherent-ahb-gpu-present.patch b/patches/termux-x11/0022-disable-incoherent-ahb-gpu-present.patch new file mode 100644 index 0000000..570b674 --- /dev/null +++ b/patches/termux-x11/0022-disable-incoherent-ahb-gpu-present.patch @@ -0,0 +1,141 @@ +lorie: reject incoherent AHB GPU Present paths + +Probe whether CPU and GLES observe the same AHardwareBuffer contents before +enabling GPU Present. Fall back to the synchronized CPU path when they do not, +without maintaining a vendor or device blacklist. + +Based on termux/termux-x11@501f72f8e1183e62925951551bf631586145c1e6 +from termux/termux-x11#1148. + +diff --git a/InitOutput.c b/InitOutput.c +--- a/InitOutput.c ++++ b/InitOutput.c +@@ -857,7 +857,7 @@ void InitOutput(ScreenInfo * screen_info, int argc, char **argv) { + screen_info->bitmapBitOrder = BITMAP_BIT_ORDER; + screen_info->numPixmapFormats = ARRAY_SIZE(depths); + +- rendererTestCapabilities(&pvfb->root.legacyDrawing); ++ rendererTestCapabilities(&pvfb->root.legacyDrawing, &pvfb->gpuPresentDisabled); + xorgGlxCreateVendor(); + lorieInitClipboard(); + +diff --git a/lorie.h b/lorie.h +--- a/lorie.h ++++ b/lorie.h +@@ -42,7 +42,7 @@ int rendererGetWakeupCondFd(void); + + __unused void rendererInit(JNIEnv* env); + __unused void rendererSetFiltering(JNIEnv* env, jobject self, jint filtering); +-__unused void rendererTestCapabilities(int* legacy_drawing); ++__unused void rendererTestCapabilities(int* legacy_drawing, int* gpu_present_disabled); + __unused void rendererSetWindow(JNIEnv *env, jobject thiz, jobject sfc); + __unused void rendererSetViewport(JNIEnv *env, jclass clazz, int x, int y, int w, int h, int ew, int eh); + __unused void rendererSetZoom(JNIEnv *env, jclass clazz, int percent); +diff --git a/renderer.c b/renderer.c +--- a/renderer.c ++++ b/renderer.c +@@ -415,7 +415,81 @@ void rendererSetFiltering(JNIEnv* env, jobject self, jint f) { + filtering = f; + } + +-void rendererTestCapabilities(int* legacy_drawing) { ++// Present GPU offload writes this buffer while Xorg keeps its CPU mapping locked. ++// Verify both views refer to coherent memory instead of guessing from the driver name. ++static bool cpuAndGpuShareAhbMemory(EGLDisplay display) { ++ const EGLint imageAttributes[] = {EGL_IMAGE_PRESERVED_KHR, EGL_TRUE, EGL_NONE}; ++ AHardwareBuffer_Desc desc = { ++ .width = 64, ++ .height = 64, ++ .layers = 1, ++ .format = AHARDWAREBUFFER_FORMAT_R8G8B8X8_UNORM, ++ .usage = AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE | AHARDWAREBUFFER_USAGE_GPU_FRAMEBUFFER | ++ AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN | AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN ++ }; ++ AHardwareBuffer *buffer = NULL; ++ EGLImageKHR image = EGL_NO_IMAGE_KHR; ++ GLuint texture = 0, fbo = 0; ++ volatile uint32_t *pixels = NULL; ++ bool result = true; ++ ++ if (AHardwareBuffer_allocate(&desc, &buffer) != 0 || !buffer) ++ return true; ++ ++ EGLClientBuffer clientBuffer = eglGetNativeClientBufferANDROID(buffer); ++ if (clientBuffer) ++ image = eglCreateImageKHR(display, EGL_NO_CONTEXT, EGL_NATIVE_BUFFER_ANDROID, clientBuffer, imageAttributes); ++ if (image == EGL_NO_IMAGE_KHR) ++ goto out; ++ ++ glGenTextures(1, &texture); ++ glBindTexture(GL_TEXTURE_2D, texture); ++ glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, image); ++ glGenFramebuffers(1, &fbo); ++ glBindFramebuffer(GL_FRAMEBUFFER, fbo); ++ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); ++ if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) ++ goto out; ++ ++ if (AHardwareBuffer_lock(buffer, AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN | AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN, ++ -1, NULL, (void **) &pixels) != 0 || !pixels) ++ goto out; ++ pixels[0] = 0; ++ ++ glClearColor(0x11 / 255.f, 0x22 / 255.f, 0x33 / 255.f, 1.f); ++ glClear(GL_COLOR_BUFFER_BIT); ++ glFinish(); ++ ++ if ((pixels[0] & 0xffffff) != 0x332211) { ++ loge("CPU mapping does not see GPU writes (got %08x)", pixels[0]); ++ AHardwareBuffer_unlock(buffer, NULL); ++ result = false; ++ goto out; ++ } ++ ++ AHardwareBuffer_unlock(buffer, NULL); ++ { ++ uint8_t pixel[4] = {0, 0, 0, 0}; ++ glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); ++ if (pixel[0] != 0x11 || pixel[1] != 0x22 || pixel[2] != 0x33) { ++ loge("CPU unlock overwrote GPU writes (got %02x%02x%02x)", pixel[0], pixel[1], pixel[2]); ++ result = false; ++ } ++ } ++ ++out: ++ glBindFramebuffer(GL_FRAMEBUFFER, 0); ++ if (fbo) ++ glDeleteFramebuffers(1, &fbo); ++ if (texture) ++ glDeleteTextures(1, &texture); ++ if (image != EGL_NO_IMAGE_KHR) ++ eglDestroyImageKHR(display, image); ++ AHardwareBuffer_release(buffer); ++ return result; ++} ++ ++void rendererTestCapabilities(int* legacy_drawing, int* gpu_present_disabled) { + // Some devices do not support sampling from HAL_PIXEL_FORMAT_BGRA_8888, here we are checking it. + const EGLint imageAttributes[] = {EGL_IMAGE_PRESERVED_KHR, EGL_TRUE, EGL_NONE}; + EGLint numConfigs; +@@ -428,7 +502,8 @@ void rendererTestCapabilities(int* legacy_drawing) { + .width = 64, + .height = 64, + .layers = 1, +- .usage = AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE | AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN | AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN, ++ .usage = AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE | AHARDWAREBUFFER_USAGE_GPU_FRAMEBUFFER | ++ AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN | AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN, + .format = AHARDWAREBUFFER_FORMAT_R8G8B8X8_UNORM + }; + +@@ -503,6 +578,11 @@ void rendererTestCapabilities(int* legacy_drawing) { + if (eglMakeCurrent(egl_display, checksfc, checksfc, testctx) != EGL_TRUE) + return vprintEglError("check eglMakeCurrent failed", __LINE__); + ++ if (!*gpu_present_disabled && !cpuAndGpuShareAhbMemory(egl_display)) { ++ loge("CPU and GPU views of AHardwareBuffer are not coherent on this device, disabling Present GPU offload"); ++ *gpu_present_disabled = 1; ++ } ++ + glActiveTexture(GL_TEXTURE0); checkGlError(); + glGenTextures(1, &texture); checkGlError(); + bindTexture(texture); diff --git a/patches/termux-x11/0023-transfer-ahb-present-ownership.patch b/patches/termux-x11/0023-transfer-ahb-present-ownership.patch new file mode 100644 index 0000000..2c52a29 --- /dev/null +++ b/patches/termux-x11/0023-transfer-ahb-present-ownership.patch @@ -0,0 +1,83 @@ +lorie: transfer AHardwareBuffer ownership around GPU Present + +AHardwareBuffer does not permit GPU writes while a CPU mapping is locked. +The previous capability probe exercised that invalid overlap, so conforming +devices disabled every GPU Present copy even though the renderer already +fences completed work. + +End the CPU mapping before the probe writes through GLES, then reacquire it +after glFinish to verify the result. Likewise release an idle destination +mapping before queuing a Present copy. EXA PrepareAccess reacquires the mapping +for later CPU work under the existing renderer state lock. + +diff --git a/InitOutput.c b/InitOutput.c +--- a/InitOutput.c ++++ b/InitOutput.c +@@ -916,5 +916,5 @@ Bool lorieTryScheduleGpuCopy(PixmapPtr pixmap, PixmapPtr dst, RegionPtr update, + LorieBuffer *srcBuffer, *dstBuffer; +- LoriePixmapPriv *priv; ++ LoriePixmapPriv *priv, *dstPriv; + const LorieBuffer_Desc *desc, *dstDesc; + LorieGpuCopyEntry *entry; + BoxRec fullBox; +@@ -941,6 +941,18 @@ Bool lorieTryScheduleGpuCopy(PixmapPtr pixmap, PixmapPtr dst, RegionPtr update, + } + priv = LORIE_PIXMAP_PRIV_FROM_PIXMAP(pixmap); ++ dstPriv = LORIE_PIXMAP_PRIV_FROM_PIXMAP(dst); + desc = LorieBuffer_description(srcBuffer); + dstDesc = LorieBuffer_description(dstBuffer); + ++ /* Transfer an idle CPU mapping back to the GPU before queuing the write. */ ++ if (dstDesc->type == LORIEBUFFER_AHARDWAREBUFFER && dstPriv && dstPriv->locked) { ++ if (LorieBuffer_unlock(dstPriv->buffer) != 0) { ++ gpuCopyAttempts++; ++ return FALSE; ++ } ++ dstPriv->locked = NULL; ++ dstPriv->wasLocked = false; ++ dst->devPrivate.ptr = NULL; ++ } ++ + if (update) { +diff --git a/renderer.c b/renderer.c +--- a/renderer.c ++++ b/renderer.c +@@ -457,19 +457,25 @@ static bool cpuAndGpuShareAhbMemory(EGLDisplay display) { + -1, NULL, (void **) &pixels) != 0 || !pixels) + goto out; + pixels[0] = 0; ++ AHardwareBuffer_unlock(buffer, NULL); ++ pixels = NULL; + + glClearColor(0x11 / 255.f, 0x22 / 255.f, 0x33 / 255.f, 1.f); + glClear(GL_COLOR_BUFFER_BIT); + glFinish(); + +- if ((pixels[0] & 0xffffff) != 0x332211) { +- loge("CPU mapping does not see GPU writes (got %08x)", pixels[0]); +- AHardwareBuffer_unlock(buffer, NULL); ++ if (AHardwareBuffer_lock(buffer, AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN, ++ -1, NULL, (void **) &pixels) != 0 || !pixels) ++ goto out; ++ uint32_t gpuPixel = pixels[0]; ++ AHardwareBuffer_unlock(buffer, NULL); ++ pixels = NULL; ++ if ((gpuPixel & 0xffffff) != 0x332211) { ++ loge("CPU mapping does not see GPU writes (got %08x)", gpuPixel); + result = false; + goto out; + } + +- AHardwareBuffer_unlock(buffer, NULL); + { + uint8_t pixel[4] = {0, 0, 0, 0}; + glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); +@@ -482,6 +489,8 @@ static bool cpuAndGpuShareAhbMemory(EGLDisplay display) { + } + + out: ++ if (pixels) ++ AHardwareBuffer_unlock(buffer, NULL); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (fbo) + glDeleteFramebuffers(1, &fbo); diff --git a/patches/termux-x11/README.md b/patches/termux-x11/README.md new file mode 100644 index 0000000..9650c07 --- /dev/null +++ b/patches/termux-x11/README.md @@ -0,0 +1,73 @@ +# Embedded Termux:X11 patch stack + +`tools/prepare-termux-x11-source.sh` applies the production series to pinned +Termux:X11 commit `0cb0203`. Order is part of the ABI: guest producers, Xorg, +Lorie, and the Android renderer must agree on buffer type, channel order, +ownership, damage, and synchronization. + +Each patch has a kernel-style subject and a short explanation of the problem it +solves. Production patches live here; noisy probes live in `diagnostics/`; +unsafe or incomplete work lives in `experiments/` and is never applied +automatically. + +## Patch inventory + +The table names the patches; `tools/prepare-termux-x11-source.sh` is the +authoritative application order because some later-numbered patches depend on +Xserver changes applied outside `lorie/`. + +| Patch | Purpose | Applied by default | +| --- | --- | --- | +| `0000` | Makes upstream nested Xserver patching idempotent when later uDroid patches extend the same files. | Yes | +| `0001` | Restores Android's `xlocale.h` include order for the embedded libX11 build. | Yes | +| `0002` | Replaces Termux:X11's JNI server entry point with uDroid's native bridge. | Yes | +| `0003` | Connects the embedded renderer and X server through uDroid's JNI bridge. | Yes | +| `0004` | Batches touch frames so one Android gesture reaches X11 as one ordered update. | Yes | +| `0005` | Synchronizes DMA-BUF CPU reads used by GLES upload fallback. | Yes | +| `0006` | Samples imported AHardwareBuffers as external textures instead of assuming ordinary 2D texture storage. | Yes | +| `0007` | Defines private RGBA/BGRA content semantics, bounds socket handshakes, and preserves AHardwareBuffer ownership on failure. | Yes | +| `0008` | Publishes the private buffer-transport contract on the X11 root window. | Yes | +| `0009` | Defers GPU-only Present work while the Android renderer is detached. | Yes | +| `0011` | Adds low-overhead Perfetto slices around draw, fence, and swap operations. | Yes | +| `0015` | Exports CPU-mappable redirected pixmaps through DRI3 using aligned DMA-heap storage. | Yes | +| `0016` | Accepts existing DMA-BUF and AHardwareBuffer pixmaps as GPU-copy sources instead of forcing a CPU fallback. | Yes | +| `0017` | Exports existing AHardwareBuffer pixmaps through DRI3 without replacing their storage. | Yes | +| `0018` | Orders Present damage, ownership, GPU copies, and CPU fallback. | Yes | +| `0019` | Synchronizes nested EXA CPU reads/writes to DMA-BUF pixmaps. | Yes | +| `0020` | Reports the active EGL/GLES import contract, eligible route, and fallback reason once at renderer startup. | Yes | +| `0021` | Keeps padded AHardwareBuffer dimensions out of the logical X pixmap size and rejects undersized imports. | Yes | +| `0022` | Disables Present GPU copies when CPU and GPU views of the same AHardwareBuffer are incoherent. | Yes | +| `0023` | Transfers AHardwareBuffer ownership between EXA CPU access and fenced GPU Present copies. | Yes | + +## Non-production patches + +| Patch | Purpose | How it is used | +| --- | --- | --- | +| `diagnostics/0012` | Logs raw-FD duplication, mmap, dimensions, and errno. | Applied with `UDROID_X11_DIAGNOSTICS=1`. | +| `experiments/0013` | Fixes an upstream width/height typo in direct flip. | Never applied: it exposes an unqualified direct-flip path whose ownership/fence contract is incomplete. | + +The diagnostic profile adds observability only; it does not change the selected +buffer transport. Production builds must not set `UDROID_X11_DIAGNOSTICS`. +Use the upstream `-disable-gpu-present` server option for CPU/GPU Present A/B +tests; no source patch is needed. Perfetto slices and X11 Present tools cover +lifecycle timing without permanent per-frame logging. + +## Validation + +Run source preparation twice for each profile. The second run verifies semantic +idempotence rather than only a clean first apply: + +```sh +./tools/prepare-termux-x11-source.sh +./tools/prepare-termux-x11-source.sh +UDROID_X11_DIAGNOSTICS=1 ./tools/prepare-termux-x11-source.sh +UDROID_X11_DIAGNOSTICS=1 ./tools/prepare-termux-x11-source.sh +./tools/prepare-termux-x11-source.sh +``` + +The final standard run also verifies diagnostic patches can be removed cleanly. +Before release, replay both profiles from a fresh pinned submodule checkout; +semantic markers prove idempotence but cannot detect stale patch context. +Then rebuild the APK. Device qualification must include a composited desktop, +normal channel colors, repeated window movement, typed repaint, and Present +offload counters; a visible first frame alone is insufficient. diff --git a/patches/termux-x11/diagnostics/0012-lorie-raw-fd-import-diagnostics.patch b/patches/termux-x11/diagnostics/0012-lorie-raw-fd-import-diagnostics.patch new file mode 100644 index 0000000..eeb96c5 --- /dev/null +++ b/patches/termux-x11/diagnostics/0012-lorie-raw-fd-import-diagnostics.patch @@ -0,0 +1,75 @@ +lorie: add raw-FD import diagnostics + +Log descriptor size, offset, stride, ownership, and errno around duplication and +mmap failures. This is intentionally limited to the opt-in diagnostics profile +because imports may generate substantial output. + +--- a/buffer.c ++++ b/buffer.c +@@ -175,9 +175,11 @@ + AHardwareBuffer_Desc desc = {0}; + static uint64_t id = 0; + bool acceptable = (format == AHARDWAREBUFFER_FORMAT_B8G8R8A8_UNORM || format == AHARDWAREBUFFER_FORMAT_R8G8B8X8_UNORM) && width > 0 && height > 0; ++ int ownedFd = takeFd ? fd : dup(fd); ++ int duplicateError = !takeFd && ownedFd < 0 ? errno : 0; + LorieBuffer b = { .desc = { .width = width, .stride = stride, .height = height, .format = format, .type = type, + .needsBgraSwizzle = format != AHARDWAREBUFFER_FORMAT_B8G8R8A8_UNORM, .buffer = buf, .id = id++ }, +- .fd = takeFd ? fd : dup(fd), .size = size, .offset = offset }; ++ .fd = ownedFd, .size = size, .offset = offset }; + + if (type != LORIEBUFFER_AHARDWAREBUFFER && !acceptable) + return NULL; +@@ -194,11 +197,27 @@ + break; + case LORIEBUFFER_FD: +- if (b.fd < 0) ++ if (b.fd < 0) { ++ int error = duplicateError ? duplicateError : EBADF; ++ struct stat info = {0}; ++ off_t fdSize = fstat(fd, &info) == 0 ? info.st_size : -1; ++ dprintf(2, "LorieBuffer: raw-fd import stage=%s source_fd=%d owned_fd=%d fd_size=%lld width=%d height=%d stride=%d offset=%lld map_size=%zu errno=%d (%s)\n", ++ takeFd ? "validate" : "dup", fd, b.fd, (long long) fdSize, ++ width, height, stride, (long long) offset, size, ++ error, strerror(error)); ++ errno = error; + return NULL; ++ } + + b.desc.data = mmap(NULL, b.size, PROT_READ|PROT_WRITE, MAP_SHARED, b.fd, b.offset); + if (b.desc.data == NULL || b.desc.data == MAP_FAILED) { ++ int error = errno; ++ struct stat info = {0}; ++ off_t fdSize = fstat(b.fd, &info) == 0 ? info.st_size : -1; ++ dprintf(2, "LorieBuffer: raw-fd import stage=mmap source_fd=%d owned_fd=%d fd_size=%lld width=%d height=%d stride=%d offset=%lld map_size=%zu errno=%d (%s)\n", ++ fd, b.fd, (long long) fdSize, width, height, stride, ++ (long long) offset, size, error, strerror(error)); + close(b.fd); ++ errno = error; + return NULL; + } + break; +--- a/InitOutput.c ++++ b/InitOutput.c +@@ -1206,10 +1206,20 @@ + priv->imported = true; + + if (modifier == DRM_FORMAT_MOD_INVALID || modifier == DRM_FORMAT_MOD_LINEAR || modifier == RAW_MMAPPABLE_FD) { +- check(!(priv->buffer = LorieBuffer_wrapFileDescriptor(width, strides[0]/4, height, AHARDWAREBUFFER_FORMAT_B8G8R8A8_UNORM, fds[0], offsets[0])), "DRI3: LorieBuffer_wrapAHardwareBuffer failed."); ++ struct stat fdInfo = {0}; ++ int fdStatResult = fstat(fds[0], &fdInfo); ++ int fdStatError = fdStatResult == 0 ? 0 : errno; ++ off_t fdSize = fdStatResult == 0 ? fdInfo.st_size : -1; ++ if (lorieServerDebugEnabled) ++ log(INFO, "DRI3: raw-fd import request modifier=%llu fd=%d fd_size=%lld dimensions=%ux%u stride=%lu offset=%lu fstat_errno=%d", ++ (unsigned long long) modifier, fds[0], (long long) fdSize, ++ width, height, (unsigned long) strides[0], (unsigned long) offsets[0], fdStatError); ++ check(!(priv->buffer = LorieBuffer_wrapFileDescriptor(width, strides[0]/4, height, AHARDWAREBUFFER_FORMAT_B8G8R8A8_UNORM, fds[0], offsets[0])), "DRI3: raw-fd import failed modifier=%llu fd=%d fd_size=%lld dimensions=%ux%u stride=%lu offset=%lu errno=%d (%s)", (unsigned long long) modifier, fds[0], (long long) fdSize, width, height, (unsigned long) strides[0], (unsigned long) offsets[0], errno, strerror(errno)); + screen->ModifyPixmapHeader(pixmap, width, height, 0, 0, strides[0], NULL); + if (lorieServerDebugEnabled) +- log(INFO, "DRI3: imported raw fd, modifier %llu, %ux%u stride %u", (unsigned long long) modifier, width, height, strides[0]); ++ log(INFO, "DRI3: raw-fd import succeeded modifier=%llu fd=%d fd_size=%lld dimensions=%ux%u stride=%lu offset=%lu", ++ (unsigned long long) modifier, fds[0], (long long) fdSize, ++ width, height, (unsigned long) strides[0], (unsigned long) offsets[0]); + return pixmap; + } + diff --git a/patches/termux-x11/experiments/0013-fix-present-flip-height-check.patch b/patches/termux-x11/experiments/0013-fix-present-flip-height-check.patch new file mode 100644 index 0000000..5e218ee --- /dev/null +++ b/patches/termux-x11/experiments/0013-fix-present-flip-height-check.patch @@ -0,0 +1,19 @@ +present: correct the direct-flip height comparison + +The upstream predicate compares root width against both pixmap dimensions and +rejects valid non-square fullscreen buffers. Keep this patch experimental: the +fix also makes direct flip reachable before uDroid has established its required +ownership and fence contract. + +--- a/InitOutput.c ++++ b/InitOutput.c +@@ -1038,7 +1038,8 @@ + + Bool loriePresentFlip(__unused RRCrtcPtr crtc, __unused uint64_t event_id, __unused uint64_t target_msc, PixmapPtr pixmap, __unused Bool sync_flip) { + LoriePixmapPriv* priv = (LoriePixmapPriv*) exaGetPixmapDriverPrivate(pixmap); +- if (!priv || !priv->buffer || priv->mem || pvfb->root.width != pixmap->drawable.width || pvfb->root.width != pixmap->drawable.height) ++ /* Compare each pixmap axis with the matching root axis. */ ++ if (!priv || !priv->buffer || priv->mem || pvfb->root.width != pixmap->drawable.width || pvfb->root.height != pixmap->drawable.height) + return FALSE; + + const LorieBuffer_Desc *desc = LorieBuffer_description(priv->buffer); diff --git a/tools/build-proot-assets.sh b/tools/build-proot-assets.sh index 8c96787..b77937d 100755 --- a/tools/build-proot-assets.sh +++ b/tools/build-proot-assets.sh @@ -6,8 +6,8 @@ NDK="${ANDROID_NDK_HOME:-${ANDROID_HOME:-$HOME/Library/Android/sdk}/ndk/28.2.136 TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin" API=26 -PROOT_VERSION=5.1.107.86 -PROOT_SHA256=692da7f952ac390eb65c4117d360cad23a052525eea4eb110ae42f8a4a7d7bb8 +PROOT_VERSION=5.1.107.101 +PROOT_SHA256=a66743f50c7b0bac1913b83bf55e139d679c80a0ca2808eec76432a65640017f TALLOC_VERSION=2.4.3 TALLOC_SHA256=dc46c40b9f46bb34dd97fe41f548b0e8b247b77a918576733c528e83abd854dd diff --git a/tools/experiments/README.md b/tools/experiments/README.md new file mode 100644 index 0000000..2683409 --- /dev/null +++ b/tools/experiments/README.md @@ -0,0 +1,19 @@ +# X11 diagnostic probes + +These small programs isolate one X11 or process-lifecycle contract at a time. +They are developer diagnostics, not application runtime dependencies. + +| Probe | Purpose | +|---|---| +| `glx-oml-sample.c` | Read GLX OML UST, MSC, and SBC values for presentation timing. | +| `ppoll-trace.c` | Preload trace for blocking `poll` and `ppoll` calls. | +| `signal-backtrace.c` | Install an opt-in signal handler that prints a native backtrace. | +| `x11-input-probe.c` | Send simple pointer and launcher-key input through Xlib. | +| `x11-interaction-probe.c` | Check display geometry and XTest input injection. | +| `xdamage-trace.c` | Preload trace for XDamage API calls. | +| `xdamage-watch.c` | Count damage events for one X11 window over a bounded interval. | +| `xkb-reply-probe.c` | Distinguish delayed XKB replies, protocol errors, and timeouts. | + +Build a probe inside the target Linux system with its matching development +libraries. Keep preload traces and signal handlers out of normal sessions: they +change timing and are intended only for short, reproducible captures. diff --git a/tools/experiments/glx-oml-sample.c b/tools/experiments/glx-oml-sample.c new file mode 100644 index 0000000..0be8709 --- /dev/null +++ b/tools/experiments/glx-oml-sample.c @@ -0,0 +1,45 @@ +#include +#include +#include +#include + +int main(void) { + Display *display = XOpenDisplay(NULL); + PFNGLXGETSYNCVALUESOMLPROC get_sync_values; + XVisualInfo *visual; + XSetWindowAttributes attributes; + Window window; + GLXContext context; + int visual_attributes[] = {GLX_RGBA, GLX_DOUBLEBUFFER, None}; + + if (!display) return 1; + visual = glXChooseVisual(display, DefaultScreen(display), visual_attributes); + if (!visual) return 2; + attributes.colormap = XCreateColormap(display, + RootWindow(display, visual->screen), + visual->visual, AllocNone); + window = XCreateWindow(display, RootWindow(display, visual->screen), + 0, 0, 64, 64, 0, visual->depth, InputOutput, + visual->visual, CWColormap, &attributes); + context = glXCreateContext(display, visual, NULL, True); + if (!window || !context || !glXMakeCurrent(display, window, context)) return 3; + get_sync_values = (PFNGLXGETSYNCVALUESOMLPROC) + glXGetProcAddressARB((const GLubyte *) "glXGetSyncValuesOML"); + if (!get_sync_values) return 4; + + for (int i = 0; i < 12; i++) { + int64_t ust = 0, msc = 0, sbc = 0; + Bool ok = get_sync_values(display, window, &ust, &msc, &sbc); + printf("ok=%d ust=%" PRId64 " msc=%" PRId64 " sbc=%" PRId64 "\n", + ok, ust, msc, sbc); + fflush(stdout); + usleep(250000); + } + + glXMakeCurrent(display, None, NULL); + glXDestroyContext(display, context); + XDestroyWindow(display, window); + XFree(visual); + XCloseDisplay(display); + return 0; +} diff --git a/tools/experiments/ppoll-trace.c b/tools/experiments/ppoll-trace.c new file mode 100644 index 0000000..281fff9 --- /dev/null +++ b/tools/experiments/ppoll-trace.c @@ -0,0 +1,79 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include + +static int (*real_poll)(struct pollfd *, nfds_t, int); +static int (*real_ppoll)(struct pollfd *, nfds_t, const struct timespec *, + const sigset_t *); +static pthread_t main_thread; +static __thread int tracing; +static __thread unsigned int traced_waits; + +__attribute__((constructor)) static void +remember_main_thread(void) +{ + main_thread = pthread_self(); +} + +static int +is_gnome_shell(void) +{ + char executable[256]; + ssize_t length = readlink("/proc/self/exe", executable, sizeof(executable) - 1); + + if (length < 0) + return 0; + executable[length] = '\0'; + + const char *name = strrchr(executable, '/'); + return strcmp(name ? name + 1 : executable, "gnome-shell") == 0; +} + +static void +trace_wait(const char *name, const struct pollfd *fds, nfds_t count, int indefinite) +{ + if (tracing || !indefinite || count != 1 || traced_waits >= 512 || + !pthread_equal(pthread_self(), main_thread) || !is_gnome_shell()) + return; + + tracing = 1; + traced_waits++; + + void *frames[48]; + int frame_count; + + dprintf(STDERR_FILENO, + "UDROID_POLL_TRACE %s seq=%u pid=%d fd=%d events=0x%x\n", + name, traced_waits, getpid(), fds[0].fd, fds[0].events); + frame_count = backtrace(frames, 48); + backtrace_symbols_fd(frames, frame_count, STDERR_FILENO); + dprintf(STDERR_FILENO, "UDROID_POLL_TRACE end\n"); + + tracing = 0; +} + +int +poll(struct pollfd *fds, nfds_t count, int timeout) +{ + if (!real_poll) + real_poll = dlsym(RTLD_NEXT, "poll"); + trace_wait("poll", fds, count, timeout < 0); + return real_poll(fds, count, timeout); +} + +int +ppoll(struct pollfd *fds, nfds_t count, const struct timespec *timeout, + const sigset_t *sigmask) +{ + if (!real_ppoll) + real_ppoll = dlsym(RTLD_NEXT, "ppoll"); + trace_wait("ppoll", fds, count, timeout == NULL); + return real_ppoll(fds, count, timeout, sigmask); +} diff --git a/tools/experiments/signal-backtrace.c b/tools/experiments/signal-backtrace.c new file mode 100644 index 0000000..f683df3 --- /dev/null +++ b/tools/experiments/signal-backtrace.c @@ -0,0 +1,51 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include + +#define TRACE_SIGNAL (SIGRTMIN + 7) + +static void +write_backtrace(int signal_number) +{ + void *frames[96]; + int count = backtrace(frames, 96); + static const char start[] = "UDROID_SIGNAL_BACKTRACE start\n"; + static const char end[] = "UDROID_SIGNAL_BACKTRACE end\n"; + + (void)signal_number; + ssize_t unused = write(STDERR_FILENO, start, sizeof(start) - 1); + backtrace_symbols_fd(frames, count, STDERR_FILENO); + unused = write(STDERR_FILENO, end, sizeof(end) - 1); + (void)unused; +} + +__attribute__((constructor)) static void +install_backtrace_signal(void) +{ + char executable[256]; + ssize_t length = readlink("/proc/self/exe", executable, + sizeof(executable) - 1); + if (length < 0) + return; + + executable[length] = '\0'; + const char *name = strrchr(executable, '/'); + name = name ? name + 1 : executable; + if (strcmp(name, "gnome-shell") != 0) + return; + + /* Prime the unwinder before a diagnostic signal interrupts the process. */ + void *frame; + backtrace(&frame, 1); + + struct sigaction action = { + .sa_handler = write_backtrace, + .sa_flags = SA_RESTART, + }; + sigemptyset(&action.sa_mask); + sigaction(TRACE_SIGNAL, &action, NULL); +} diff --git a/tools/experiments/x11-input-probe.c b/tools/experiments/x11-input-probe.c new file mode 100644 index 0000000..34880b5 --- /dev/null +++ b/tools/experiments/x11-input-probe.c @@ -0,0 +1,46 @@ +#include +#include +#include +#include +#include + +extern Bool XTestFakeKeyEvent(Display *, unsigned int, Bool, unsigned long); +extern Bool XTestFakeMotionEvent(Display *, int, int, int, unsigned long); +extern Bool XTestFakeButtonEvent(Display *, unsigned int, Bool, unsigned long); + +int +main(int argc, char **argv) +{ + Display *display = XOpenDisplay(NULL); + + if (!display) { + fputs("could not open DISPLAY\n", stderr); + return 1; + } + + if (argc == 4 && strcmp(argv[1], "move") == 0) { + if (!XTestFakeMotionEvent(display, DefaultScreen(display), atoi(argv[2]), atoi(argv[3]), 0)) + return 2; + } else if (argc == 2 && strcmp(argv[1], "click") == 0) { + if (!XTestFakeButtonEvent(display, 1, True, 0) || + !XTestFakeButtonEvent(display, 1, False, 0)) + return 2; + } else if (argc == 2 && strcmp(argv[1], "show-apps") == 0) { + KeyCode super = XKeysymToKeycode(display, XK_Super_L); + KeyCode a = XKeysymToKeycode(display, XK_a); + + if (!super || !a || + !XTestFakeKeyEvent(display, super, True, 0) || + !XTestFakeKeyEvent(display, a, True, 0) || + !XTestFakeKeyEvent(display, a, False, 0) || + !XTestFakeKeyEvent(display, super, False, 0)) + return 2; + } else { + fprintf(stderr, "usage: %s move X Y | click | show-apps\n", argv[0]); + return 2; + } + + XSync(display, False); + XCloseDisplay(display); + return 0; +} diff --git a/tools/experiments/x11-interaction-probe.c b/tools/experiments/x11-interaction-probe.c new file mode 100644 index 0000000..3cb609f --- /dev/null +++ b/tools/experiments/x11-interaction-probe.c @@ -0,0 +1,64 @@ +#include +#include +#include + +#include +#include +#include +#include + +static int +parse_coordinate(const char *value, int *coordinate) +{ + char *end; + errno = 0; + long parsed = strtol(value, &end, 10); + if (errno || *value == '\0' || *end != '\0' || parsed < 0 || parsed > 32767) + return 0; + + *coordinate = parsed; + return 1; +} + +int +main(int argc, char **argv) +{ + Display *display = XOpenDisplay(NULL); + if (!display) { + fprintf(stderr, "could not open DISPLAY\n"); + return 1; + } + + int screen = DefaultScreen(display); + if (argc == 2 && strcmp(argv[1], "--size") == 0) { + printf("%dx%d\n", DisplayWidth(display, screen), DisplayHeight(display, screen)); + } else if (argc == 2 && strcmp(argv[1], "--show-apps") == 0) { + KeyCode super = XKeysymToKeycode(display, XK_Super_L); + KeyCode a = XKeysymToKeycode(display, XK_A); + if (!super || !a || + !XTestFakeKeyEvent(display, super, True, CurrentTime) || + !XTestFakeKeyEvent(display, a, True, CurrentTime) || + !XTestFakeKeyEvent(display, a, False, CurrentTime) || + !XTestFakeKeyEvent(display, super, False, CurrentTime)) { + fprintf(stderr, "could not inject Super+A\n"); + XCloseDisplay(display); + return 1; + } + } else if (argc == 4 && strcmp(argv[1], "--move") == 0) { + int x, y; + if (!parse_coordinate(argv[2], &x) || !parse_coordinate(argv[3], &y) || + !XTestFakeMotionEvent(display, screen, x, y, CurrentTime)) { + fprintf(stderr, "could not inject pointer move\n"); + XCloseDisplay(display); + return 1; + } + } else { + fprintf(stderr, "usage: %s --size | --show-apps | --move X Y\n", argv[0]); + XCloseDisplay(display); + return 2; + } + + XSync(display, False); + XCloseDisplay(display); + return 0; +} diff --git a/tools/experiments/xdamage-trace.c b/tools/experiments/xdamage-trace.c new file mode 100644 index 0000000..c008c54 --- /dev/null +++ b/tools/experiments/xdamage-trace.c @@ -0,0 +1,54 @@ +#define _GNU_SOURCE +#include +#include + +#include +#include +#include + +static FILE *trace_file(void) { + static FILE *file; + if (!file) + file = fopen("/tmp/udroid-xdamage-trace.log", "a"); + return file; +} + +static void trace(const char *operation, unsigned long drawable, + unsigned long damage, int level) { + FILE *file = trace_file(); + struct timespec now; + if (!file) + return; + clock_gettime(CLOCK_MONOTONIC, &now); + fprintf(file, "%lld.%09ld %s drawable=0x%lx damage=0x%lx level=%d\n", + (long long)now.tv_sec, now.tv_nsec, operation, drawable, damage, + level); + fflush(file); +} + +Damage XDamageCreate(Display *display, Drawable drawable, int level) { + static Damage (*next)(Display *, Drawable, int); + Damage damage; + if (!next) + next = dlsym(RTLD_NEXT, "XDamageCreate"); + damage = next(display, drawable, level); + trace("create", drawable, damage, level); + return damage; +} + +void XDamageSubtract(Display *display, Damage damage, XserverRegion repair, + XserverRegion parts) { + static void (*next)(Display *, Damage, XserverRegion, XserverRegion); + if (!next) + next = dlsym(RTLD_NEXT, "XDamageSubtract"); + trace("subtract", repair, damage, 0); + next(display, damage, repair, parts); +} + +void XDamageDestroy(Display *display, Damage damage) { + static void (*next)(Display *, Damage); + if (!next) + next = dlsym(RTLD_NEXT, "XDamageDestroy"); + trace("destroy", 0, damage, 0); + next(display, damage); +} diff --git a/tools/experiments/xdamage-watch.c b/tools/experiments/xdamage-watch.c new file mode 100644 index 0000000..ad9e01d --- /dev/null +++ b/tools/experiments/xdamage-watch.c @@ -0,0 +1,55 @@ +#include +#include + +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + Display *display; + Window window; + Damage damage; + XEvent event; + int damage_event; + int damage_error; + unsigned long count = 0; + time_t deadline; + + if (argc != 3) { + fprintf(stderr, "usage: %s WINDOW_ID SECONDS\n", argv[0]); + return 2; + } + + errno = 0; + window = (Window)strtoul(argv[1], NULL, 0); + deadline = time(NULL) + strtol(argv[2], NULL, 10); + if (errno || !window || deadline <= time(NULL)) + return 2; + + display = XOpenDisplay(NULL); + if (!display || !XDamageQueryExtension(display, &damage_event, &damage_error)) + return 3; + + damage = XDamageCreate(display, window, XDamageReportRawRectangles); + XSync(display, False); + while (time(NULL) < deadline) { + while (XPending(display)) { + XNextEvent(display, &event); + if (event.type == damage_event + XDamageNotify) { + XDamageNotifyEvent *notify = (XDamageNotifyEvent *)&event; + printf("damage=%lu drawable=0x%lx area=%dx%d+%d+%d\n", + ++count, notify->drawable, notify->area.width, + notify->area.height, notify->area.x, notify->area.y); + } + } + struct timespec pause = {.tv_nsec = 10000000}; + nanosleep(&pause, NULL); + } + + printf("total=%lu\n", count); + XDamageDestroy(display, damage); + XCloseDisplay(display); + return 0; +} diff --git a/tools/experiments/xkb-reply-probe.c b/tools/experiments/xkb-reply-probe.c new file mode 100644 index 0000000..c8650cc --- /dev/null +++ b/tools/experiments/xkb-reply-probe.c @@ -0,0 +1,133 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct request { + const char *name; + unsigned int sequence; + int complete; +}; + +static int64_t +milliseconds(void) +{ + struct timespec now; + + clock_gettime(CLOCK_MONOTONIC, &now); + return now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +int +main(void) +{ + xcb_connection_t *connection = xcb_connect(NULL, NULL); + if (xcb_connection_has_error(connection)) { + fputs("connect failed\n", stderr); + return 1; + } + + if (!xkb_x11_setup_xkb_extension(connection, + XKB_X11_MIN_MAJOR_XKB_VERSION, + XKB_X11_MIN_MINOR_XKB_VERSION, + XKB_X11_SETUP_XKB_EXTENSION_NO_FLAGS, + NULL, NULL, NULL, NULL)) { + fputs("XKB setup failed\n", stderr); + return 1; + } + + int32_t device = xkb_x11_get_core_keyboard_device_id(connection); + if (device < 0) { + fputs("core keyboard lookup failed\n", stderr); + return 1; + } + printf("device=%d\n", device); + + const xcb_xkb_map_part_t map_parts = + XCB_XKB_MAP_PART_KEY_TYPES | + XCB_XKB_MAP_PART_KEY_SYMS | + XCB_XKB_MAP_PART_MODIFIER_MAP | + XCB_XKB_MAP_PART_EXPLICIT_COMPONENTS | + XCB_XKB_MAP_PART_KEY_ACTIONS | + XCB_XKB_MAP_PART_VIRTUAL_MODS | + XCB_XKB_MAP_PART_VIRTUAL_MOD_MAP; + const xcb_xkb_name_detail_t names = + XCB_XKB_NAME_DETAIL_KEYCODES | + XCB_XKB_NAME_DETAIL_SYMBOLS | + XCB_XKB_NAME_DETAIL_TYPES | + XCB_XKB_NAME_DETAIL_COMPAT | + XCB_XKB_NAME_DETAIL_KEY_TYPE_NAMES | + XCB_XKB_NAME_DETAIL_KT_LEVEL_NAMES | + XCB_XKB_NAME_DETAIL_INDICATOR_NAMES | + XCB_XKB_NAME_DETAIL_KEY_NAMES | + XCB_XKB_NAME_DETAIL_KEY_ALIASES | + XCB_XKB_NAME_DETAIL_VIRTUAL_MOD_NAMES | + XCB_XKB_NAME_DETAIL_GROUP_NAMES; + + xcb_xkb_get_map_cookie_t map = + xcb_xkb_get_map(connection, device, map_parts, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + xcb_xkb_get_indicator_map_cookie_t indicators = + xcb_xkb_get_indicator_map(connection, device, UINT32_MAX); + xcb_xkb_get_compat_map_cookie_t compat = + xcb_xkb_get_compat_map(connection, device, 0, 1, 0, 0); + xcb_xkb_get_names_cookie_t key_names = + xcb_xkb_get_names(connection, device, names); + xcb_xkb_get_controls_cookie_t controls = + xcb_xkb_get_controls(connection, device); + + struct request requests[] = { + { "GetMap", map.sequence, 0 }, + { "GetIndicatorMap", indicators.sequence, 0 }, + { "GetCompatMap", compat.sequence, 0 }, + { "GetNames", key_names.sequence, 0 }, + { "GetControls", controls.sequence, 0 }, + }; + const size_t request_count = sizeof(requests) / sizeof(requests[0]); + size_t completed = 0; + int64_t deadline = milliseconds() + 3000; + + xcb_flush(connection); + for (size_t i = 0; i < request_count; i++) + printf("sent %s sequence=%u\n", requests[i].name, requests[i].sequence); + fflush(stdout); + + while (completed < request_count && milliseconds() < deadline) { + for (size_t i = 0; i < request_count; i++) { + void *reply = NULL; + xcb_generic_error_t *error = NULL; + + if (requests[i].complete || + !xcb_poll_for_reply(connection, requests[i].sequence, + &reply, &error)) + continue; + + requests[i].complete = 1; + completed++; + printf("reply %s sequence=%u status=%s error=%u\n", + requests[i].name, requests[i].sequence, + reply ? "ok" : "error", error ? error->error_code : 0); + free(reply); + free(error); + } + + struct pollfd fd = { xcb_get_file_descriptor(connection), POLLIN, 0 }; + poll(&fd, 1, 20); + } + + for (size_t i = 0; i < request_count; i++) { + if (!requests[i].complete) + printf("timeout %s sequence=%u\n", + requests[i].name, requests[i].sequence); + } + + xcb_disconnect(connection); + return completed == request_count ? 0 : 2; +} diff --git a/tools/prepare-termux-x11-source.sh b/tools/prepare-termux-x11-source.sh index e7777bb..eaaa250 100755 --- a/tools/prepare-termux-x11-source.sh +++ b/tools/prepare-termux-x11-source.sh @@ -81,58 +81,87 @@ apply_once \ "$repo_root/patches/termux-x11/0004-udroid-batched-native-touch.patch" \ 'EVENT_TOUCH_FRAME' -if [[ "${UDROID_EXPERIMENTAL_GFXSTREAM_X11:-0}" == "1" ]]; then - source_profile="experimental gfxstream" - revert_if_applied \ - "$cpp_root/lorie" \ - "$repo_root/patches/termux-x11/0010-disable-gpu-present-copy.patch" \ - "uDroid standard profile: use Xorg's copy path" - apply_once \ - "$cpp_root/lorie" \ - "$repo_root/patches/termux-x11/0005-dmabuf-cpu-read-sync.patch" \ - 'DMA_BUF_IOCTL_SYNC' - apply_once \ - "$cpp_root/lorie" \ - "$repo_root/patches/termux-x11/0006-ahardwarebuffer-external-texture-sampling.patch" \ - 'GL_TEXTURE_EXTERNAL_OES' - apply_once \ - "$cpp_root/lorie" \ - "$repo_root/patches/termux-x11/0007-ahardwarebuffer-content-semantics.patch" \ - 'AHARDWAREBUFFER_RGBA_SOCKET_FD' +# AHardwareBuffer and Present-copy handling is shared by all embedded X11 builds. +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0005-dmabuf-cpu-read-sync.patch" \ + 'DMA_BUF_IOCTL_SYNC' +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0006-ahardwarebuffer-external-texture-sampling.patch" \ + 'GL_TEXTURE_EXTERNAL_OES' +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0007-ahardwarebuffer-content-semantics.patch" \ + 'AHARDWAREBUFFER_RGBA_SOCKET_FD' +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0008-advertise-buffer-transport-protocol.patch" \ + 'UDROID_X11_BUFFER_TRANSPORT_ATOM' +apply_once \ + "$cpp_root" \ + "$repo_root/patches/termux-x11/0009-defer-gpu-only-present-while-detached.patch" \ + 'Bool loriePixmapIsGpuOnly(PixmapPtr pixmap) {' +revert_if_applied \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/experiments/0013-fix-present-flip-height-check.patch" \ + 'pvfb->root.height != pixmap->drawable.height' + +if [[ "${UDROID_X11_DIAGNOSTICS:-0}" == "1" ]]; then + source_profile="diagnostics" apply_once \ "$cpp_root/lorie" \ - "$repo_root/patches/termux-x11/0008-advertise-buffer-transport-protocol.patch" \ - 'UDROID_X11_BUFFER_TRANSPORT_ATOM' - apply_once \ - "$cpp_root" \ - "$repo_root/patches/termux-x11/0009-defer-gpu-only-present-while-detached.patch" \ - 'Bool loriePixmapRequiresGpuCopy(PixmapPtr pixmap) {' + "$repo_root/patches/termux-x11/diagnostics/0012-lorie-raw-fd-import-diagnostics.patch" \ + 'raw-fd import stage=mmap' else source_profile="standard" - revert_if_applied \ - "$cpp_root" \ - "$repo_root/patches/termux-x11/0009-defer-gpu-only-present-while-detached.patch" \ - 'Bool loriePixmapRequiresGpuCopy(PixmapPtr pixmap) {' - revert_if_applied \ - "$cpp_root/lorie" \ - "$repo_root/patches/termux-x11/0008-advertise-buffer-transport-protocol.patch" \ - 'UDROID_X11_BUFFER_TRANSPORT_ATOM' - revert_if_applied \ - "$cpp_root/lorie" \ - "$repo_root/patches/termux-x11/0007-ahardwarebuffer-content-semantics.patch" \ - 'AHARDWAREBUFFER_RGBA_SOCKET_FD' revert_if_applied \ "$cpp_root/lorie" \ - "$repo_root/patches/termux-x11/0006-ahardwarebuffer-external-texture-sampling.patch" \ - 'GL_TEXTURE_EXTERNAL_OES' - revert_if_applied \ - "$cpp_root/lorie" \ - "$repo_root/patches/termux-x11/0005-dmabuf-cpu-read-sync.patch" \ - 'DMA_BUF_IOCTL_SYNC' - apply_once \ - "$cpp_root/lorie" \ - "$repo_root/patches/termux-x11/0010-disable-gpu-present-copy.patch" \ - "uDroid standard profile: use Xorg's copy path" + "$repo_root/patches/termux-x11/diagnostics/0012-lorie-raw-fd-import-diagnostics.patch" \ + 'raw-fd import stage=mmap' fi +# Compositors import redirected window pixmaps through DRI3 in every profile. +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0015-export-pixmaps-through-dri3.patch" \ + 'DRI3: exported DMA-BUF pixmap' +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0016-accept-fd-gpu-copy-sources.patch" \ + 'lorieGpuCopySource' +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0017-export-ahardwarebuffer-pixmaps-through-dri3.patch" \ + 'exportAHardwareBufferDmaBuf' +apply_once \ + "$cpp_root" \ + "$repo_root/patches/termux-x11/0018-present-damage-ordering.patch" \ + 'RegionPtr gpu_copy_damage' +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0019-dmabuf-exa-cpu-access-sync.patch" \ + 'LorieBuffer_cpuAccessBegin' +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0020-renderer-route-capability-probe.patch" \ + 'Xlorie: G0 graphics contract' +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0021-preserve-logical-ahb-pixmap-size.patch" \ + 'logical %ux%u physical %ux%u' +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0022-disable-incoherent-ahb-gpu-present.patch" \ + 'CPU and GPU views of AHardwareBuffer are not coherent' +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0023-transfer-ahb-present-ownership.patch" \ + 'Transfer an idle CPU mapping back to the GPU' + +apply_once \ + "$cpp_root/lorie" \ + "$repo_root/patches/termux-x11/0011-lorie-perfetto-slices.patch" \ + 'uDroid/Lorie draw' + echo "Termux:X11 native source is ready ($source_profile profile)." diff --git a/tools/profiling/venus-perfetto.pbtxt b/tools/profiling/venus-perfetto.pbtxt new file mode 100644 index 0000000..eb57dbf --- /dev/null +++ b/tools/profiling/venus-perfetto.pbtxt @@ -0,0 +1,75 @@ +duration_ms: 15000 + +buffers { + size_kb: 65536 + fill_policy: RING_BUFFER +} + +data_sources { + config { + name: "linux.ftrace" + ftrace_config { + ftrace_events: "sched/sched_switch" + ftrace_events: "sched/sched_waking" + ftrace_events: "sched/sched_wakeup_new" + ftrace_events: "power/cpu_frequency" + ftrace_events: "power/gpu_frequency" + ftrace_events: "power/gpu_work_period" + ftrace_events: "gpu_mem/gpu_mem_total" + ftrace_events: "mali/mali_JM_SUBMIT" + ftrace_events: "mali/mali_JM_JOB_DONE" + ftrace_events: "mali/mali_job_slots_event" + atrace_categories: "gfx" + atrace_categories: "input" + atrace_categories: "sched" + atrace_categories: "view" + atrace_apps: "org.randomcoder.udroid" + atrace_apps: "org.randomcoder.udroid.dev" + compact_sched { + enabled: true + } + } + } +} + +data_sources { + config { + name: "android.surfaceflinger.frame" + } +} + +data_sources { + config { + name: "android.surfaceflinger.frametimeline" + } +} + +data_sources { + config { + name: "android.surfaceflinger.layers" + surfaceflinger_layers_config { + mode: MODE_ACTIVE + trace_flags: TRACE_FLAG_COMPOSITION + trace_flags: TRACE_FLAG_BUFFERS + } + } +} + +data_sources { + config { + name: "android.gpu.memory" + } +} + +data_sources { + config { + name: "linux.process_stats" + process_stats_config { + scan_all_processes_on_start: true + proc_stats_poll_ms: 1000 + } + } +} + +write_into_file: true +file_write_period_ms: 1000 diff --git a/tools/proot-ndk28.patch b/tools/proot-ndk28.patch index 1c22fc6..6d0a341 100644 --- a/tools/proot-ndk28.patch +++ b/tools/proot-ndk28.patch @@ -1,6 +1,6 @@ --- a/src/extension/ashmem_memfd/ashmem_memfd.c +++ b/src/extension/ashmem_memfd/ashmem_memfd.c -@@ -3,6 +3,7 @@ +@@ -3,4 +3,5 @@ #include +#include #include diff --git a/tools/test_udroid_debug.py b/tools/test_udroid_debug.py new file mode 100644 index 0000000..743fb38 --- /dev/null +++ b/tools/test_udroid_debug.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 + +import importlib.util +from pathlib import Path +import unittest + + +SPEC = importlib.util.spec_from_file_location( + "udroid_debug", + Path(__file__).with_name("udroid-debug.py"), +) +UDROID_DEBUG = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(UDROID_DEBUG) + + +class UdroidDebugTest(unittest.TestCase): + def test_finds_accessibility_node_center(self): + document = """""" + + self.assertEqual( + (1021, 190), + UDROID_DEBUG.accessibility_node_center( + document, + "Collapse desktop controls", + "org.randomcoder.udroid.dev", + ), + ) + + def test_missing_accessibility_node_returns_none(self): + self.assertIsNone( + UDROID_DEBUG.accessibility_node_center( + '', + "Collapse desktop controls", + ) + ) + + def test_ignores_another_apps_accessibility_node(self): + self.assertIsNone( + UDROID_DEBUG.accessibility_node_center( + '', + "Collapse desktop controls", + "org.randomcoder.udroid.dev", + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/udroid-debug.md b/tools/udroid-debug.md new file mode 100644 index 0000000..9c57d6a --- /dev/null +++ b/tools/udroid-debug.md @@ -0,0 +1,31 @@ +# Dev-build ADB controls + +`udroid-debug.py` talks to the installed `org.randomcoder.udroid.dev` app. It uses +an ADB-shell-only receiver and is not included in release builds. It invokes the +same supervised session paths as the UI; it does not run arbitrary guest commands. + +```sh +python3 tools/udroid-debug.py roots +python3 tools/udroid-debug.py status +python3 tools/udroid-debug.py start-desktop --rootfs noble-raw --environment gnome +python3 tools/udroid-debug.py open-desktop +python3 tools/udroid-debug.py logs --limit 40 +python3 tools/udroid-debug.py stop-desktop +``` + +Other controls: `open-system`, `start-terminal`, and `stop-terminal`. Add +`--serial DEVICE` when multiple devices are connected. Set `ADB` to the full +`adb` executable path if it is not on `PATH`. Commands are queued; `status` +reports whether the supervised operation actually started. + +## Panfrost damage reproduction + +Start a clean Panfrost GNOME session, launch Alacritty through uDroid's normal +Linux-app path, and collapse the Android display controls: + +```sh +python3 tools/udroid-debug.py repro-damage +``` + +Then drag the Alacritty window and type `glxgears` one character at a time. +Use `--keep-controls` when the expanded controls are part of the comparison. diff --git a/tools/udroid-debug.py b/tools/udroid-debug.py new file mode 100644 index 0000000..a3145fe --- /dev/null +++ b/tools/udroid-debug.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Inspect and control a connected uDroid dev build without tapping its UI.""" + +import argparse +import json +import os +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + + +def adb(args, *, serial=None, check=True): + command = [os.environ.get("ADB", "adb")] + if serial: + command += ["-s", serial] + result = subprocess.run(command + args, capture_output=True, text=True) + if check and result.returncode: + raise RuntimeError((result.stderr or result.stdout).strip()) + return result + + +def private_file(package, path, serial): + return adb(["exec-out", "run-as", package, "cat", path], serial=serial).stdout + + +def preferences(package, name, serial): + result = adb( + ["exec-out", "run-as", package, "cat", f"shared_prefs/{name}.xml"], + serial=serial, + check=False, + ) + if result.returncode: + if "No such file or directory" in result.stderr: + return {} + raise RuntimeError((result.stderr or result.stdout).strip()) + root = ET.fromstring(result.stdout) + return { + element.attrib["name"]: element.attrib.get("value", element.text or "") + for element in root + } + + +def show_status(package, serial): + state = preferences(package, "runtime-supervisor-state", serial) + active = preferences(package, "installed-rootfs-state", serial).get("active-rootfs", "none") + print(f"Active Linux: {active}") + print( + f"Terminal: {state.get('phase', 'unknown')} · " + f"{state.get('rootfs-name', 'none')} · {state.get('message', '')}" + ) + print( + f"Desktop: {state.get('desktop-phase', 'unknown')} · " + f"{state.get('desktop-environment-name', 'none')} · " + f"display :{state.get('desktop-display-number', '?')} · " + f"{state.get('desktop-message', '')}" + ) + print(f"Boot ID: {state.get('boot-id', 'none')}") + + +def show_roots(package, serial): + result = adb( + ["exec-out", "run-as", package, "ls", "-1", "files/rootfs"], + serial=serial, + check=False, + ) + if result.returncode: + print("No Linux systems found") + return + for name in result.stdout.splitlines(): + if not name or "/" in name: + continue + marker = f"files/rootfs/{name}/.udroid-ready" + ready = adb( + ["exec-out", "run-as", package, "test", "-f", marker], + serial=serial, + check=False, + ).returncode == 0 + print(f"{name}{'' if ready else ' (incomplete)'}") + + +def show_logs(package, serial, limit, raw): + lines = private_file(package, "files/logs/supervisor.jsonl", serial).splitlines()[-limit:] + for line in lines: + if raw: + print(line) + continue + event = json.loads(line) + print( + f"{event.get('timestamp', '?')} " + f"[{event.get('component', '?')}/{event.get('severity', '?')}] " + f"{event.get('event', '?')}: {event.get('message', '')}" + ) + + +def wait_for_desktop(package, serial, rootfs, timeout): + deadline = time.monotonic() + timeout + last_message = "Desktop state is unavailable" + while time.monotonic() < deadline: + state = preferences(package, "runtime-supervisor-state", serial) + phase = state.get("desktop-phase", "unknown") + last_message = state.get("desktop-message", last_message) + if phase == "RUNNING" and state.get("rootfs-name") == rootfs: + return + if phase == "CRASHED": + raise RuntimeError(last_message) + time.sleep(0.5) + raise RuntimeError(f"Timed out waiting for {rootfs} desktop: {last_message}") + + +def launch_application(package, serial, rootfs, application_id): + adb( + [ + "shell", "am", "start", "-n", + f"{package}/org.randomcoder.udroid.MainActivity", + "-a", "org.randomcoder.udroid.action.LAUNCH_LINUX_APPLICATION", + "--es", "org.randomcoder.udroid.extra.LINUX_APPLICATION_ID", application_id, + "--es", "org.randomcoder.udroid.extra.ROOTFS_NAME", rootfs, + "-f", "0x24000000", # CLEAR_TOP | SINGLE_TOP + ], + serial=serial, + ) + + +def accessibility_node_center(document, description, package=None): + root = ET.fromstring(document) + for node in root.iter("node"): + if node.attrib.get("content-desc") != description: + continue + if package and node.attrib.get("package") != package: + continue + match = re.fullmatch( + r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", + node.attrib.get("bounds", ""), + ) + if match: + left, top, right, bottom = map(int, match.groups()) + return (left + right) // 2, (top + bottom) // 2 + return None + + +def collapse_desktop_controls(package, serial, timeout): + remote = "/sdcard/udroid-debug-window.xml" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + adb(["shell", "rm", "-f", remote], serial=serial, check=False) + adb(["shell", "uiautomator", "dump", remote], serial=serial, check=False) + result = adb(["exec-out", "cat", remote], serial=serial, check=False) + if result.returncode == 0: + center = accessibility_node_center( + result.stdout, + "Collapse desktop controls", + package, + ) + if center: + adb(["shell", "input", "tap", str(center[0]), str(center[1])], serial=serial) + return + time.sleep(0.5) + raise RuntimeError("Desktop opened, but its Collapse desktop controls button was not found") + + +def send_command(package, serial, command, rootfs, environment): + args = [ + "shell", "am", "broadcast", "--receiver-foreground", + "-n", f"{package}/org.randomcoder.udroid.DebugCommandReceiver", + "-a", "org.randomcoder.udroid.action.DEBUG_COMMAND", + "--es", "command", command, + ] + if rootfs: + args += ["--es", "rootfs", rootfs] + if environment: + args += ["--es", "environment", environment] + result = adb(args, serial=serial) + output = result.stdout.strip() + if not re.search(r"result=-1(?:\D|$)", output): + raise RuntimeError(output or "uDroid did not accept the debug command") + adb( + [ + "shell", "am", "start", "-n", + f"{package}/org.randomcoder.udroid.MainActivity", + "-f", "0x24000000", # CLEAR_TOP | SINGLE_TOP + ], + serial=serial, + ) + print(output) + + +def reproduce_damage(package, serial, rootfs, environment, application, timeout, keep_controls): + state = preferences(package, "runtime-supervisor-state", serial) + if state.get("desktop-phase") != "STOPPED": + send_command(package, serial, "stop-desktop", None, None) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + state = preferences(package, "runtime-supervisor-state", serial) + if state.get("desktop-phase") == "STOPPED": + break + time.sleep(0.5) + else: + raise RuntimeError("Timed out stopping the existing desktop") + send_command(package, serial, "start-desktop", rootfs, environment) + wait_for_desktop(package, serial, rootfs, timeout) + launch_application(package, serial, rootfs, application) + if not keep_controls: + collapse_desktop_controls(package, serial, timeout) + adb(["shell", "input", "keyevent", "KEYCODE_ESCAPE"], serial=serial) + print( + f"Damage reproduction ready: {environment} + {application}. " + "Drag the window, then type glxgears one character at a time." + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--serial", help="ADB device serial") + parser.add_argument("--package", default="org.randomcoder.udroid.dev") + sub = parser.add_subparsers(dest="command", required=True) + for name in ("status", "roots", "open-system", "open-desktop", "start-terminal", "stop-terminal", "start-desktop", "stop-desktop"): + command = sub.add_parser(name) + if name in ("open-system", "start-terminal", "start-desktop"): + command.add_argument("--rootfs", help="Installed Linux system; defaults to active") + if name == "start-desktop": + command.add_argument("--environment", help="Desktop session ID, e.g. gnome or xfce") + logs = sub.add_parser("logs") + logs.add_argument("--limit", type=int, default=80) + logs.add_argument("--raw", action="store_true", help="Print original JSONL") + damage = sub.add_parser("repro-damage", help="Open the Panfrost GNOME damage reproduction") + damage.add_argument("--rootfs", default="noble-raw") + damage.add_argument("--environment", default="udroid-gnome-panfrost") + damage.add_argument("--application", default="Alacritty", help="Desktop application ID") + damage.add_argument("--timeout", type=float, default=30) + damage.add_argument("--keep-controls", action="store_true") + args = parser.parse_args() + try: + if args.command == "status": + show_status(args.package, args.serial) + elif args.command == "roots": + show_roots(args.package, args.serial) + elif args.command == "logs": + if args.limit < 1: + parser.error("--limit must be positive") + show_logs(args.package, args.serial, args.limit, args.raw) + elif args.command == "repro-damage": + if args.timeout <= 0: + parser.error("--timeout must be positive") + reproduce_damage( + args.package, + args.serial, + args.rootfs, + args.environment, + args.application, + args.timeout, + args.keep_controls, + ) + else: + send_command( + args.package, + args.serial, + args.command, + getattr(args, "rootfs", None), + getattr(args, "environment", None), + ) + except (OSError, RuntimeError, ET.ParseError, json.JSONDecodeError) as error: + print(f"udroid-debug: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())