diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 98262e3..c74daba 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -31,6 +31,7 @@ kotlin {
android {
namespace = "org.randomcoder.udroid"
compileSdk = 36
+ ndkVersion = "28.2.13676358"
testBuildType = "probe"
defaultConfig {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d3f721e..883a6bc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,9 +10,14 @@
+
+
+
diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/AndroidStorageMounts.kt b/app/src/main/java/org/randomcoder/udroid/runtime/AndroidStorageMounts.kt
new file mode 100644
index 0000000..fefdcfe
--- /dev/null
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/AndroidStorageMounts.kt
@@ -0,0 +1,107 @@
+package org.randomcoder.udroid.runtime
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.os.storage.StorageManager
+import android.provider.Settings
+import androidx.core.content.ContextCompat
+import java.io.File
+
+data class AndroidStorageVolume(
+ val label: String,
+ val hostPath: String,
+ val state: String,
+ val primary: Boolean,
+ val removable: Boolean,
+) {
+ val mounted: Boolean
+ get() = state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY
+
+ val guestTarget: String
+ get() = AndroidStorageMounts.guestTarget(primary, hostPath)
+}
+
+object AndroidStorageMounts {
+ fun discover(context: Context): List {
+ val manager = context.getSystemService(StorageManager::class.java)
+ val volumes = manager.storageVolumes
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ return volumes.filter { it.isPrimary }.mapNotNull { volume ->
+ volume.directory?.let { directory ->
+ AndroidStorageVolume(
+ label = volume.getDescription(context),
+ hostPath = directory.absolutePath,
+ state = volume.state,
+ primary = volume.isPrimary,
+ removable = volume.isRemovable,
+ )
+ }
+ }
+ }
+
+ val marker = "/Android/data/${context.packageName}/files"
+ return context.getExternalFilesDirs(null).mapIndexedNotNull { index, appDirectory ->
+ val path = appDirectory?.absolutePath ?: return@mapIndexedNotNull null
+ val root = path.substringBefore(marker).takeIf { it != path } ?: return@mapIndexedNotNull null
+ val volume =
+ volumes.firstOrNull { candidate ->
+ if (index == 0) {
+ candidate.isPrimary
+ } else {
+ !candidate.isPrimary &&
+ candidate.uuid?.equals(File(root).name, ignoreCase = true) == true
+ }
+ }
+ AndroidStorageVolume(
+ label = volume?.getDescription(context) ?: if (index == 0) "Internal shared storage" else "External storage",
+ hostPath = root,
+ state = Environment.getExternalStorageState(appDirectory),
+ primary = index == 0,
+ removable = volume?.isRemovable ?: index != 0,
+ )
+ }.filter(AndroidStorageVolume::primary)
+ .distinctBy(AndroidStorageVolume::hostPath)
+ }
+
+ fun hasFullAccess(context: Context): Boolean =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Environment.isExternalStorageManager()
+ } else {
+ ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) ==
+ PackageManager.PERMISSION_GRANTED &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
+ PackageManager.PERMISSION_GRANTED
+ }
+
+ fun accessIntent(context: Context): Intent =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val appSettings = Intent(
+ Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
+ Uri.parse("package:${context.packageName}"),
+ )
+ appSettings.takeIf { it.resolveActivity(context.packageManager) != null }
+ ?: Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
+ } else {
+ Intent()
+ }
+
+ fun legacyPermissions(): Array =
+ arrayOf(
+ Manifest.permission.READ_EXTERNAL_STORAGE,
+ Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ )
+
+ internal fun guestTarget(
+ primary: Boolean,
+ hostPath: String,
+ ): String {
+ if (primary) return "/mnt/shared"
+ val volumeId = File(hostPath).name.lowercase().ifBlank { "external" }
+ return "/mnt/storage/$volumeId"
+ }
+}
diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt
index 629a1a0..1553974 100644
--- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt
+++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt
@@ -104,18 +104,6 @@ object ProotMountProfileValidator {
requireSafePath(mount.guestTarget, "Guest target")
}
- val enabledTargets =
- buildList {
- PROOT_DEFAULT_MOUNTS
- .filter { profile.isDefaultEnabled(it.id) }
- .forEach { add(it.guestTarget) }
- profile.customMounts.filter(ProotCustomMount::enabled).forEach {
- add(it.guestTarget)
- }
- }
- require(enabledTargets.distinct().size == enabledTargets.size) {
- "Enabled mappings must use unique guest destinations"
- }
return profile
}
@@ -172,17 +160,6 @@ object ProotMountResolver {
}
addAll(sessionMounts)
}
- val duplicateTarget =
- resolved
- .groupingBy(ResolvedProotMount::guestTarget)
- .eachCount()
- .entries
- .firstOrNull { it.value > 1 }
- ?.key
- require(duplicateTarget == null) {
- "$duplicateTarget is already mounted by an active session feature; disable that " +
- "feature or choose another Linux path"
- }
return resolved
}
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 bcc111d..7355c53 100644
--- a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt
+++ b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt
@@ -140,6 +140,8 @@ fun LinuxSystemPage(
RuntimePhase.RUNNING,
RuntimePhase.STOPPING,
)
+ val runtimeStopping =
+ snapshot.rootfsName == rootfs.name && snapshot.phase == RuntimePhase.STOPPING
val desktopBlocksMaintenance =
desktop.rootfsName == rootfs.name &&
desktop.phase in
@@ -476,11 +478,19 @@ fun LinuxSystemPage(
if (runtimeBlocksMaintenance) {
OutlinedButton(
modifier = Modifier.weight(1f),
+ enabled = !runtimeStopping,
onClick = onStopTerminal,
) {
- Icon(Icons.Rounded.Stop, contentDescription = null)
+ if (runtimeStopping) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ )
+ } else {
+ Icon(Icons.Rounded.Stop, contentDescription = null)
+ }
Text(
- "Stop terminal",
+ if (runtimeStopping) "Stopping…" else "Stop terminal",
modifier = Modifier.padding(start = 6.dp),
)
}
diff --git a/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfileDialog.kt b/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfileDialog.kt
index 60eb299..e00f49b 100644
--- a/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfileDialog.kt
+++ b/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfileDialog.kt
@@ -146,6 +146,17 @@ fun ProotMountProfileDialog(
}
}
+ AndroidStorageMountsCard(
+ enabled = true,
+ mounts = draft.customMounts,
+ onAdd = { mount ->
+ draft = draft.copy(customMounts = draft.customMounts + mount)
+ validationMessage =
+ "${mount.hostSource} will be available at ${mount.guestTarget}"
+ },
+ onMessage = { validationMessage = it },
+ )
+
Column {
Text(
"Session mounts",
diff --git a/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfilesPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfilesPage.kt
index 941843e..b5b05de 100644
--- a/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfilesPage.kt
+++ b/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfilesPage.kt
@@ -1,6 +1,13 @@
package org.randomcoder.udroid.ui
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.BackHandler
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -40,7 +47,9 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -52,6 +61,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -60,6 +70,8 @@ import org.randomcoder.udroid.catalog.LinuxDistribution
import org.randomcoder.udroid.gfxstream.GfxstreamGuestRuntime
import org.randomcoder.udroid.install.InstallProgress
import org.randomcoder.udroid.runtime.GFXSTREAM_PROFILE_ENABLED
+import org.randomcoder.udroid.runtime.AndroidStorageMounts
+import org.randomcoder.udroid.runtime.AndroidStorageVolume
import org.randomcoder.udroid.runtime.InstalledRootfs
import org.randomcoder.udroid.runtime.PROOT_DEFAULT_MOUNTS
import org.randomcoder.udroid.runtime.ProotCustomMount
@@ -598,24 +610,16 @@ fun ProotMountConfigurationEditorPage(
}
}
- item(key = "configuration-session-label") {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Column(modifier = Modifier.weight(1f)) {
- Text("Session mounts", style = MaterialTheme.typography.titleMedium)
- Text(
- "Added only while the owning feature is active",
- color = UdroidMuted,
- style = MaterialTheme.typography.bodySmall,
- )
- }
- TextButton(onClick = ::requestSessionFeatures) {
- Text("Manage features")
- }
- }
- }
-
- item(key = "configuration-session-mounts") {
- AutomaticSessionMounts()
+ item(key = "configuration-android-storage") {
+ AndroidStorageMountsCard(
+ enabled = editingEnabled,
+ mounts = draft.customMounts,
+ onAdd = { mount ->
+ draft = draft.copy(customMounts = draft.customMounts + mount)
+ message = "${mount.hostSource} will be available at ${mount.guestTarget}"
+ },
+ onMessage = { message = it },
+ )
}
item(key = "configuration-custom-label") {
@@ -680,6 +684,26 @@ fun ProotMountConfigurationEditorPage(
}
}
+ item(key = "configuration-session-label") {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("Session mounts", style = MaterialTheme.typography.titleMedium)
+ Text(
+ "Added only while the owning feature is active",
+ color = UdroidMuted,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ TextButton(onClick = ::requestSessionFeatures) {
+ Text("Manage features")
+ }
+ }
+ }
+
+ item(key = "configuration-session-mounts") {
+ AutomaticSessionMounts()
+ }
+
(message ?: externalMessage)?.let { visibleMessage ->
item(key = "configuration-message") {
Text(
@@ -814,6 +838,187 @@ private fun ProotMountProfile.updateCustomMount(
): ProotMountProfile =
copy(customMounts = customMounts.map { if (it.id == id) update(it) else it })
+@Composable
+internal fun AndroidStorageMountsCard(
+ enabled: Boolean,
+ mounts: List,
+ onAdd: (ProotCustomMount) -> Unit,
+ onMessage: (String) -> Unit,
+) {
+ val context = LocalContext.current
+ var refreshKey by remember { mutableIntStateOf(0) }
+ val hasFullAccess = remember(refreshKey) { AndroidStorageMounts.hasFullAccess(context) }
+ val volumes = remember(refreshKey) { AndroidStorageMounts.discover(context) }
+ DisposableEffect(context) {
+ val receiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(
+ ignoredContext: Context,
+ ignoredIntent: Intent,
+ ) {
+ refreshKey++
+ }
+ }
+ val filter =
+ IntentFilter().apply {
+ addAction(Intent.ACTION_MEDIA_MOUNTED)
+ addAction(Intent.ACTION_MEDIA_UNMOUNTED)
+ addAction(Intent.ACTION_MEDIA_REMOVED)
+ addAction(Intent.ACTION_MEDIA_EJECT)
+ addAction(Intent.ACTION_MEDIA_BAD_REMOVAL)
+ addDataScheme("file")
+ }
+ ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_EXPORTED)
+ onDispose { context.unregisterReceiver(receiver) }
+ }
+ val settingsLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ refreshKey++
+ onMessage(
+ if (AndroidStorageMounts.hasFullAccess(context)) {
+ "Android storage access granted"
+ } else {
+ "Android storage access was not granted"
+ },
+ )
+ }
+ val permissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
+ refreshKey++
+ onMessage(
+ if (AndroidStorageMounts.hasFullAccess(context)) {
+ "Android storage access granted"
+ } else {
+ "Android storage access was not granted"
+ },
+ )
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Column {
+ Text("Android storage", style = MaterialTheme.typography.titleMedium)
+ Text(
+ if (hasFullAccess) {
+ "Choose internal shared storage"
+ } else {
+ "Full file access is required before Linux can use shared storage"
+ },
+ color = UdroidMuted,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ if (!hasFullAccess) {
+ TextButton(
+ modifier = Modifier.align(Alignment.End),
+ enabled = enabled,
+ onClick = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ runCatching {
+ settingsLauncher.launch(AndroidStorageMounts.accessIntent(context))
+ }.onFailure {
+ onMessage("Open Android Settings and allow all files access for uDroid")
+ }
+ } else {
+ permissionLauncher.launch(AndroidStorageMounts.legacyPermissions())
+ }
+ },
+ ) {
+ Text("Allow access")
+ }
+ }
+ }
+
+ if (!hasFullAccess) {
+ Surface(color = UdroidWarningSurface, shape = MaterialTheme.shapes.medium) {
+ Text(
+ "This permission lets uDroid read and write shared files, but Linux only " +
+ "receives volumes you add below.",
+ modifier = Modifier.padding(12.dp),
+ color = UdroidWarning,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+
+ Surface(
+ color = Color.Transparent,
+ border = BorderStroke(1.dp, UdroidLine),
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ if (volumes.isEmpty()) {
+ Text(
+ "Internal shared storage was not detected.",
+ modifier = Modifier.padding(14.dp),
+ color = UdroidMuted,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ } else {
+ Column {
+ volumes.forEachIndexed { index, volume ->
+ AndroidStorageVolumeRow(
+ volume = volume,
+ enabled = enabled,
+ hasFullAccess = hasFullAccess,
+ mounts = mounts,
+ onAdd = onAdd,
+ )
+ if (index != volumes.lastIndex) HorizontalDivider(color = UdroidLine)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AndroidStorageVolumeRow(
+ volume: AndroidStorageVolume,
+ enabled: Boolean,
+ hasFullAccess: Boolean,
+ mounts: List,
+ onAdd: (ProotCustomMount) -> Unit,
+) {
+ val alreadyAdded = mounts.any { it.hostSource == volume.hostPath }
+ ListItem(
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ headlineContent = { Text(volume.label) },
+ supportingContent = {
+ Column {
+ Text(
+ volume.hostPath,
+ fontFamily = FontFamily.Monospace,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Text(
+ when {
+ !volume.mounted -> "Not mounted"
+ alreadyAdded -> "Already configured below; add another mapping if needed"
+ !hasFullAccess -> "Access required"
+ volume.state == android.os.Environment.MEDIA_MOUNTED_READ_ONLY -> "Read only"
+ else -> "Mount inside Linux at ${volume.guestTarget}"
+ },
+ color = UdroidMuted,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ },
+ trailingContent = {
+ TextButton(
+ enabled = enabled && hasFullAccess && volume.mounted,
+ onClick = {
+ onAdd(
+ ProotCustomMount(
+ hostSource = volume.hostPath,
+ guestTarget = volume.guestTarget,
+ ),
+ )
+ },
+ ) {
+ Text(if (alreadyAdded) "Add another" else "Add")
+ }
+ },
+ )
+}
+
@Composable
internal fun AutomaticSessionMounts() {
Surface(
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 7087b0b..7a465df 100644
--- a/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt
+++ b/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt
@@ -324,8 +324,18 @@ private fun LiveTerminal(
val context = LocalContext.current
val density = LocalDensity.current
val modifiers = remember { TerminalModifierState() }
+ val preferences =
+ remember(context) {
+ context.applicationContext.getSharedPreferences(
+ TERMINAL_PREFERENCES,
+ Context.MODE_PRIVATE,
+ )
+ }
val initialTextSize = remember(density) {
- with(density) { 15.sp.toPx().roundToInt() }
+ preferences.getInt(
+ KEY_TEXT_SIZE_PX,
+ with(density) { 15.sp.toPx().roundToInt() },
+ ).coerceIn(MIN_TEXT_SIZE_PX.toInt(), MAX_TEXT_SIZE_PX.toInt())
}
val client =
remember(session) {
@@ -333,6 +343,9 @@ private fun LiveTerminal(
context = context,
modifiers = modifiers,
initialTextSize = initialTextSize,
+ onTextSizeChanged = {
+ preferences.edit().putInt(KEY_TEXT_SIZE_PX, it).apply()
+ },
)
}
val terminalView =
@@ -449,6 +462,7 @@ private class UdroidTerminalViewClient(
private val context: Context,
private val modifiers: TerminalModifierState,
initialTextSize: Int,
+ private val onTextSizeChanged: (Int) -> Unit,
) : TerminalViewClient {
private var terminalView: TerminalView? = null
private var textSize = initialTextSize.toFloat()
@@ -459,7 +473,9 @@ private class UdroidTerminalViewClient(
override fun onScale(scale: Float): Float {
textSize = (textSize * scale).coerceIn(MIN_TEXT_SIZE_PX, MAX_TEXT_SIZE_PX)
- terminalView?.setTextSize(textSize.roundToInt())
+ val roundedTextSize = textSize.roundToInt()
+ terminalView?.setTextSize(roundedTextSize)
+ onTextSizeChanged(roundedTextSize)
return 1f
}
@@ -565,9 +581,9 @@ private class UdroidTerminalViewClient(
) {
Log.e(tag, error.message, error)
}
-
- private companion object {
- const val MIN_TEXT_SIZE_PX = 20f
- const val MAX_TEXT_SIZE_PX = 64f
- }
}
+
+private const val TERMINAL_PREFERENCES = "terminal-view"
+private const val KEY_TEXT_SIZE_PX = "text-size-px"
+private const val MIN_TEXT_SIZE_PX = 20f
+private const val MAX_TEXT_SIZE_PX = 64f
diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/AndroidStorageMountsTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/AndroidStorageMountsTest.kt
new file mode 100644
index 0000000..b4d6719
--- /dev/null
+++ b/app/src/test/java/org/randomcoder/udroid/runtime/AndroidStorageMountsTest.kt
@@ -0,0 +1,15 @@
+package org.randomcoder.udroid.runtime
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class AndroidStorageMountsTest {
+ @Test
+ fun `storage targets are stable and distinguish removable volumes`() {
+ assertEquals("/mnt/shared", AndroidStorageMounts.guestTarget(true, "/storage/emulated/0"))
+ assertEquals(
+ "/mnt/storage/1234-abcd",
+ AndroidStorageMounts.guestTarget(false, "/storage/1234-ABCD"),
+ )
+ }
+}
diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt
index f7811f4..adf58ad 100644
--- a/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt
+++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt
@@ -52,7 +52,7 @@ class ProotMountProfilesTest {
}
@Test
- fun `duplicate enabled guest destinations are rejected`() {
+ fun `duplicate enabled guest destinations preserve user order`() {
val profile =
ProotMountProfile(
customMounts =
@@ -65,9 +65,9 @@ class ProotMountProfilesTest {
),
)
- val failure = runCatching { ProotMountProfileValidator.requireValid(profile) }.exceptionOrNull()
-
- assertTrue(failure is IllegalArgumentException)
+ val mounts = ProotMountResolver.resolve(profile)
+ assertEquals(2, mounts.count { it.guestTarget == "/sys" })
+ assertEquals("/another/sys", mounts.last { it.guestTarget == "/sys" }.hostSource)
}
@Test
@@ -82,21 +82,20 @@ class ProotMountProfilesTest {
assertEquals(customX11.guestTarget, ProotMountResolver.resolve(profile).last().guestTarget)
- val failure =
- runCatching {
- ProotMountResolver.resolve(
- profile,
- sessionMounts =
- listOf(
- ResolvedProotMount(
- hostSource = "/data/local/automatic-x11",
- guestTarget = "/tmp/.X11-unix",
- origin = "runtime:x11",
- ),
+ val mounts =
+ ProotMountResolver.resolve(
+ profile,
+ sessionMounts =
+ listOf(
+ ResolvedProotMount(
+ hostSource = "/data/local/automatic-x11",
+ guestTarget = "/tmp/.X11-unix",
+ origin = "runtime:x11",
),
- )
- }.exceptionOrNull()
- assertTrue(failure?.message?.contains("active session feature") == true)
+ ),
+ )
+ assertEquals(2, mounts.count { it.guestTarget == "/tmp/.X11-unix" })
+ assertEquals("runtime:x11", mounts.last().origin)
}
@Test
diff --git a/patches/termux-x11/0010-disable-gpu-present-copy.patch b/patches/termux-x11/0010-disable-gpu-present-copy.patch
new file mode 100644
index 0000000..4a5d4bc
--- /dev/null
+++ b/patches/termux-x11/0010-disable-gpu-present-copy.patch
@@ -0,0 +1,12 @@
+--- 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/tools/prepare-termux-x11-source.sh b/tools/prepare-termux-x11-source.sh
index 4747702..e7777bb 100755
--- a/tools/prepare-termux-x11-source.sh
+++ b/tools/prepare-termux-x11-source.sh
@@ -33,6 +33,23 @@ apply_once() {
patch -p1 -f -N -V none -d "$source_dir" -i "$patch_file"
}
+revert_if_applied() {
+ local source_dir="$1"
+ local patch_file="$2"
+ local applied_marker="$3"
+
+ if ! grep -R -F -q -- "$applied_marker" "$source_dir"; then
+ return
+ fi
+
+ if ! patch -p1 -f -R --dry-run -d "$source_dir" -i "$patch_file" >/dev/null; then
+ echo "Termux:X11 patch cannot be reverted cleanly: $patch_file" >&2
+ exit 1
+ fi
+
+ patch -p1 -f -R -V none -d "$source_dir" -i "$patch_file"
+}
+
apply_once \
"$cpp_root" \
"$repo_root/patches/termux-x11/0000-xserver-patch-semantic-idempotence.patch" \
@@ -63,25 +80,59 @@ apply_once \
"$cpp_root/lorie" \
"$repo_root/patches/termux-x11/0004-udroid-batched-native-touch.patch" \
'EVENT_TOUCH_FRAME'
-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 loriePixmapRequiresGpuCopy(PixmapPtr pixmap) {'
-echo "Termux:X11 native source patches are ready."
+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'
+ 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) {'
+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"
+fi
+
+echo "Termux:X11 native source is ready ($source_profile profile)."