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)."