diff --git a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt index 8ec1be3..c0623ae 100644 --- a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt +++ b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt @@ -59,6 +59,8 @@ import org.randomcoder.udroid.runtime.DesktopEnvironment import org.randomcoder.udroid.runtime.DesktopEnvironmentScanner import org.randomcoder.udroid.runtime.DesktopSessionPhase import org.randomcoder.udroid.runtime.InstalledRootfs +import org.randomcoder.udroid.runtime.ProotMountProfile +import org.randomcoder.udroid.runtime.ProotMountProfileValidator import org.randomcoder.udroid.runtime.RuntimePhase import org.randomcoder.udroid.runtime.RuntimeSnapshot import org.randomcoder.udroid.runtime.RuntimeSupervisorService @@ -254,6 +256,9 @@ class MainActivity : ComponentActivity() { onResetRootfs = { rootfsName, fallback -> resetRootfs(rootfsName, fallback) }, + onCreateRootfsVariation = { rootfsName, fallback, profile -> + createRootfsVariation(rootfsName, fallback, profile) + }, onDeleteRootfs = { deleteRootfs(it) }, onSelectDesktopEnvironment = { selectDesktopEnvironment(it) }, onCompositingChanged = { updateCompositing(it) }, @@ -675,6 +680,118 @@ class MainActivity : ComponentActivity() { ) } + private fun createRootfsVariation( + rootfsName: String, + fallbackDistro: DistroVariant?, + profile: ProotMountProfile, + ) { + if (installProgress != null) { + rootfsMaintenanceMessage = "Finish the current Linux setup before creating another distro" + return + } + if (installedRootfses.none { it.name == rootfsName }) { + rootfsMaintenanceMessage = "Linux system $rootfsName is no longer installed" + return + } + + val previousWork = + runCatching { app.rootfsInstallSources.load(rootfsName) } + .getOrNull() + ?: fallbackDistro + ?.takeIf { it.internalName == rootfsName } + ?.let { + InstallerWorkRequest.Archive( + distro = it, + operationId = UUID.randomUUID().toString(), + ) + } + if (previousWork == null) { + rootfsMaintenanceMessage = + "The original image source was not recorded for this legacy install. " + + "Install it again before creating a variation." + return + } + + val variationProfile = + runCatching { + ProotMountProfileValidator.requireValid( + profile.copy(sourceSystemId = profile.sourceSystemId ?: rootfsName), + ) + }.getOrElse { + rootfsMaintenanceMessage = it.message ?: "The mount configuration is invalid" + return + } + val (installationName, _) = nextVariationIdentity(previousWork) + val variationDisplayName = "${previousWork.displayName} · ${variationProfile.name}" + val variationWork = + when (previousWork) { + is InstallerWorkRequest.Archive -> + previousWork.copy( + operationId = UUID.randomUUID().toString(), + installationName = installationName, + displayName = variationDisplayName, + ) + is InstallerWorkRequest.Oci -> + previousWork.copy( + operationId = UUID.randomUUID().toString(), + installationName = installationName, + displayName = variationDisplayName, + ) + } + + lifecycleScope.launch { + val prepared = + runCatching { + withContext(Dispatchers.IO) { + app.mountProfiles.save( + installationName, + variationProfile.independentCopy(), + ) + } + app.installState.save(InstallationSelection.initial(variationWork)) + } + prepared.fold( + onSuccess = { progress -> + installProgress = progress + showInstallTerminal = false + rootfsMaintenanceMessage = null + selectDestination(UdroidDestination.INSTALL) + }, + onFailure = { + runCatching { + withContext(Dispatchers.IO) { + app.mountProfiles.remove(installationName) + } + } + rootfsMaintenanceMessage = + it.message ?: "The Linux variation could not be prepared" + }, + ) + } + } + + private fun nextVariationIdentity(work: InstallerWorkRequest): Pair { + val baseName = + when (work) { + is InstallerWorkRequest.Archive -> work.distro.internalName + is InstallerWorkRequest.Oci -> work.installationName.replace(VARIATION_SUFFIX, "") + } + val occupiedNames = + installedRootfses.mapTo(mutableSetOf(), InstalledRootfs::name).apply { + installProgress?.installationName?.let(::add) + } + for (number in 2..999) { + val suffix = "-v$number" + val prefix = + baseName + .take(MAX_INSTALLATION_NAME_LENGTH - suffix.length) + .trimEnd('.', '-', '_') + val candidate = "$prefix$suffix" + if (candidate !in occupiedNames) return candidate to number + } + error("No available variation name for $baseName") + } + private fun maintainRootfs( rootfsName: String, resetWork: InstallProgress?, @@ -724,6 +841,9 @@ class MainActivity : ComponentActivity() { ?.let { cleanupWarnings += it.message ?: "launcher shortcuts" } if (resetWork == null) { + runCatching { app.mountProfiles.remove(rootfsName) } + .exceptionOrNull() + ?.let { cleanupWarnings += it.message ?: "mount profile" } runCatching { app.rootfsInstallSources.remove(rootfsName) } .exceptionOrNull() ?.let { cleanupWarnings += it.message ?: "install source" } @@ -1264,5 +1384,7 @@ class MainActivity : ComponentActivity() { private companion object { const val NOTIFICATION_PERMISSION_REQUEST = 101 const val STATE_OCI_REPOSITORY = "oci-repository" + const val MAX_INSTALLATION_NAME_LENGTH = 96 + val VARIATION_SUFFIX = Regex("-v[2-9][0-9]*$") } } diff --git a/app/src/main/java/org/randomcoder/udroid/UdroidApplication.kt b/app/src/main/java/org/randomcoder/udroid/UdroidApplication.kt index 8159d01..13c23a0 100644 --- a/app/src/main/java/org/randomcoder/udroid/UdroidApplication.kt +++ b/app/src/main/java/org/randomcoder/udroid/UdroidApplication.kt @@ -7,6 +7,7 @@ import org.randomcoder.udroid.install.InstallStateStore import org.randomcoder.udroid.install.InstalledRootfsSourceStore import org.randomcoder.udroid.runtime.EventJournal import org.randomcoder.udroid.runtime.InstalledRootfsRegistry +import org.randomcoder.udroid.runtime.ProotMountProfileStore import org.randomcoder.udroid.runtime.RuntimeStateMachine import org.randomcoder.udroid.runtime.RuntimeStateStore import org.randomcoder.udroid.update.AppUpdateScheduler @@ -32,6 +33,9 @@ class UdroidApplication : Application() { lateinit var rootfsRegistry: InstalledRootfsRegistry private set + lateinit var mountProfiles: ProotMountProfileStore + private set + override fun onCreate() { super.onCreate() runtimeState = RuntimeStateStore(this) @@ -40,6 +44,7 @@ class UdroidApplication : Application() { rootfsInstallSources = InstalledRootfsSourceStore(this) updateState = AppUpdateStateStore(this) rootfsRegistry = InstalledRootfsRegistry(this) + mountProfiles = ProotMountProfileStore(this) if (!isMainProcess()) return updateState.reconcileInstalledVersion(BuildConfig.VERSION_NAME) diff --git a/app/src/main/java/org/randomcoder/udroid/install/InstallProgress.kt b/app/src/main/java/org/randomcoder/udroid/install/InstallProgress.kt index ded678d..0f04f69 100644 --- a/app/src/main/java/org/randomcoder/udroid/install/InstallProgress.kt +++ b/app/src/main/java/org/randomcoder/udroid/install/InstallProgress.kt @@ -116,21 +116,35 @@ data class InstallProgress( object InstallationSelection { fun initial(distro: DistroVariant): InstallProgress = + initial( + InstallerWorkRequest.Archive( + distro = distro, + operationId = UUID.randomUUID().toString(), + ), + ) + + fun initial(work: InstallerWorkRequest): InstallProgress = InstallProgress( - work = - InstallerWorkRequest.Archive( - distro = distro, - operationId = UUID.randomUUID().toString(), - ), + work = work, stage = InstallStage.READY, stageProgress = 0f, - currentDetail = "SHA-256 metadata is available for ${distro.architecture}", + currentDetail = "Configure this distro, then start its image download", terminalLines = - listOf( - "\$ udroid pull --plan ${distro.id}", - "[ready] ${distro.downloadUrl.substringAfterLast('/')}", - "[ready] sha256 ${distro.sha256.take(16)}…", - ), + when (work) { + is InstallerWorkRequest.Archive -> + listOf( + "\$ udroid pull --plan ${work.distro.id}", + "[ready] ${work.distro.downloadUrl.substringAfterLast('/')}", + "[ready] install as ${work.installationName}", + "[ready] sha256 ${work.distro.sha256.take(16)}…", + ) + is InstallerWorkRequest.Oci -> + listOf( + "\$ udroid pull --plan ${work.reference}", + "[ready] install as ${work.installationName}", + "[ready] platform ${work.platform.os}/${work.platform.architecture}", + ) + }, previewOnly = false, ) } diff --git a/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt b/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt index ba8210b..8fe9c82 100644 --- a/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt +++ b/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt @@ -194,7 +194,7 @@ class InstallerService : Service() { val distro = work.distro val operationId = work.operationId val rootfsDirectory = File(filesDir, "rootfs") - val installedRootfs = File(rootfsDirectory, distro.internalName) + val installedRootfs = File(rootfsDirectory, work.installationName) if (File(installedRootfs, RootfsInstallationPipeline.READY_MARKER).isFile) { publishCompleted(work, installedRootfs, reused = true) finishOperation(operation) @@ -444,7 +444,7 @@ class InstallerService : Service() { val operationId = work.operationId RootfsInstallationPipeline.clearInterruptedInstallation( rootfsDirectory = rootfsDirectory, - installationName = distro.internalName, + installationName = work.installationName, ) RootfsStoragePreflight.requireSpace(archive, rootfsDirectory) progressPublisher.configure( @@ -479,7 +479,7 @@ class InstallerService : Service() { RootfsInstallRequest( archive = archive, rootfsDirectory = rootfsDirectory, - installationName = distro.internalName, + installationName = work.installationName, operationId = operationId, ), onExtractionProgress = progressPublisher::extract, 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 8f1b336..f901e76 100644 --- a/app/src/main/java/org/randomcoder/udroid/install/InstallerWorkRequest.kt +++ b/app/src/main/java/org/randomcoder/udroid/install/InstallerWorkRequest.kt @@ -23,9 +23,9 @@ sealed interface InstallerWorkRequest { data class Archive( val distro: DistroVariant, override val operationId: String, + override val installationName: String = distro.internalName, + override val displayName: String = distro.releaseName, ) : InstallerWorkRequest { - override val installationName: String = distro.internalName - override val displayName: String = distro.releaseName override val architecture: String = distro.architecture } @@ -55,6 +55,7 @@ object InstallerWorkRequestCodec { when (request) { is InstallerWorkRequest.Archive -> { put("source", SOURCE_ARCHIVE) + put("source_internal_name", request.distro.internalName) put("suite", request.distro.suite) put("variant", request.distro.variant) put("friendly_name", request.distro.friendlyName) @@ -112,7 +113,11 @@ object InstallerWorkRequestCodec { DistroVariant( suite = value.requiredString("suite"), variant = value.requiredString("variant"), - internalName = installationName, + internalName = + value["source_internal_name"] + ?.jsonPrimitive + ?.contentOrNull + ?: installationName, friendlyName = value.requiredString("friendly_name"), architecture = architecture, downloadUrl = value.requiredString("download_url"), @@ -143,6 +148,8 @@ object InstallerWorkRequestCodec { ?.intOrNull ?: 0, ), + installationName = installationName, + displayName = displayName, ) SOURCE_OCI -> diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt index 7aacf85..49fca4e 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt @@ -10,6 +10,7 @@ data class ProotApplicationLaunch( val command: List, val workingDirectory: File, val environment: Map, + val mounts: List, ) object ProotApplicationLaunchBuilder { @@ -28,6 +29,15 @@ object ProotApplicationLaunchBuilder { application.workingDirectory .takeIf { guestPathExists(rootfs, it, directory = true) } ?: guestHome + val mounts = + ProotMountResolver.resolve( + profile = ProotMountProfileStore(context).load(rootfs.name), + sessionMounts = + ProotMountResolver.sessionMounts( + x11SocketDirectory = x11SocketDirectory.absolutePath, + audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, + ), + ) val prootArguments = buildArguments( prootPath = runtime.executable.absolutePath, @@ -38,6 +48,7 @@ object ProotApplicationLaunchBuilder { applicationArguments = listOf(application.executable) + application.arguments, audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, + mounts = mounts, ) val temporaryDirectory = File(context.cacheDir, "proot").apply { @@ -62,6 +73,7 @@ object ProotApplicationLaunchBuilder { val separator = it.indexOf('=') it.substring(0, separator) to it.substring(separator + 1) }, + mounts = mounts, ) } @@ -73,6 +85,8 @@ object ProotApplicationLaunchBuilder { guestWorkingDirectory: String, applicationArguments: List, audioAuthDirectory: String? = null, + mounts: List = + ProotMountResolver.defaults(x11SocketDirectory, audioAuthDirectory), ): List { require(applicationArguments.isNotEmpty()) return buildList { @@ -81,13 +95,7 @@ object ProotApplicationLaunchBuilder { add("--kill-on-exit") add("--root-id") add("--rootfs=$rootfsPath") - addAndroidProotBindMounts() - add("-b") - add("$x11SocketDirectory:/tmp/.X11-unix") - if (audioAuthDirectory != null) { - add("-b") - add("$audioAuthDirectory:${AudioEndpoint.GUEST_AUTH_DIRECTORY}") - } + addProotBindMounts(mounts) add("--cwd=$guestWorkingDirectory") add("/usr/bin/env") add("-i") diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotBindMounts.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotBindMounts.kt index c127eb7..dce88b5 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotBindMounts.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotBindMounts.kt @@ -1,18 +1,14 @@ package org.randomcoder.udroid.runtime internal val ANDROID_PROOT_BIND_MOUNTS = - listOf( - "/system", - "/apex", - "/dev", - "/proc", - "/sys", - "/linkerconfig/ld.config.txt", - ) + PROOT_DEFAULT_MOUNTS.map(ProotDefaultMount::hostSource) -internal fun MutableList.addAndroidProotBindMounts() { - ANDROID_PROOT_BIND_MOUNTS.forEach { path -> +internal fun MutableList.addProotBindMounts(mounts: List) { + mounts.forEach { mount -> add("-b") - add(path) + add(mount.argument) } } + +internal fun MutableList.addAndroidProotBindMounts() = + addProotBindMounts(ProotMountResolver.defaults()) diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt index b480fd6..df2c2e2 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt @@ -21,6 +21,15 @@ object ProotDesktopLaunchBuilder { } require(x11SocketDirectory.isDirectory) { "The X11 socket directory is unavailable" } val guestHome = if (File(rootfs, "root").isDirectory) "/root" else "/" + val mounts = + ProotMountResolver.resolve( + profile = ProotMountProfileStore(context).load(rootfs.name), + sessionMounts = + ProotMountResolver.sessionMounts( + x11SocketDirectory = x11SocketDirectory.absolutePath, + audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, + ), + ) val arguments = buildArguments( prootPath = runtime.executable.absolutePath, @@ -30,6 +39,7 @@ object ProotDesktopLaunchBuilder { environment = environment, configuration = configuration, audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, + mounts = mounts, hasDbusRunSession = File(rootfs, "usr/bin/dbus-run-session").isFile || File(rootfs, "bin/dbus-run-session").isFile, @@ -57,6 +67,7 @@ object ProotDesktopLaunchBuilder { val separator = it.indexOf('=') it.substring(0, separator) to it.substring(separator + 1) }, + mounts = mounts, ) } @@ -69,6 +80,8 @@ object ProotDesktopLaunchBuilder { configuration: DesktopConfiguration, hasDbusRunSession: Boolean, audioAuthDirectory: String? = null, + mounts: List = + ProotMountResolver.defaults(x11SocketDirectory, audioAuthDirectory), ): List = buildList { add(prootPath) @@ -76,13 +89,7 @@ object ProotDesktopLaunchBuilder { add("--kill-on-exit") add("--root-id") add("--rootfs=$rootfsPath") - addAndroidProotBindMounts() - add("-b") - add("$x11SocketDirectory:/tmp/.X11-unix") - if (audioAuthDirectory != null) { - add("-b") - add("$audioAuthDirectory:${AudioEndpoint.GUEST_AUTH_DIRECTORY}") - } + addProotBindMounts(mounts) add("--cwd=$guestHome") add("/usr/bin/env") add("-i") diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt new file mode 100644 index 0000000..3fc59ab --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt @@ -0,0 +1,382 @@ +package org.randomcoder.udroid.runtime + +import android.content.Context +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.io.File +import java.io.FileOutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.UUID + +data class ProotDefaultMount( + val id: String, + val hostSource: String, + val guestTarget: String = hostSource, + val label: String, +) + +val PROOT_DEFAULT_MOUNTS = + listOf( + ProotDefaultMount("android.system", "/system", label = "Device system"), + ProotDefaultMount("android.apex", "/apex", label = "Android runtime"), + ProotDefaultMount("android.dev", "/dev", label = "Device interfaces"), + ProotDefaultMount("android.proc", "/proc", label = "Process information"), + ProotDefaultMount("android.sys", "/sys", label = "Kernel information"), + ProotDefaultMount( + "android.linkerconfig", + "/linkerconfig/ld.config.txt", + label = "Android linker configuration", + ), + ) + +data class ProotCustomMount( + val id: String = UUID.randomUUID().toString(), + val enabled: Boolean = true, + val hostSource: String, + val guestTarget: String, +) + +data class ProotMountProfile( + val name: String = "Default profile", + val sourceSystemId: String? = null, + val defaultOverrides: Map = emptyMap(), + val customMounts: List = emptyList(), +) { + fun isDefaultEnabled(id: String): Boolean = defaultOverrides[id] ?: true + + fun withDefaultEnabled( + id: String, + enabled: Boolean, + ): ProotMountProfile { + require(PROOT_DEFAULT_MOUNTS.any { it.id == id }) { "Unknown default mount $id" } + val overrides = defaultOverrides.toMutableMap() + if (enabled) overrides.remove(id) else overrides[id] = false + return copy(defaultOverrides = overrides) + } + + fun independentCopy(): ProotMountProfile = + copy(customMounts = customMounts.map { it.copy(id = UUID.randomUUID().toString()) }) +} + +data class ResolvedProotMount( + val hostSource: String, + val guestTarget: String, + val origin: String, +) { + val argument: String + get() = if (hostSource == guestTarget) hostSource else "$hostSource:$guestTarget" +} + +object ProotMountProfileValidator { + fun requireValid(profile: ProotMountProfile): ProotMountProfile { + require(profile.name.isNotBlank() && profile.name == profile.name.trim()) { + "Profile name must not be blank" + } + require(profile.name.length <= MAX_PROFILE_NAME_LENGTH) { + "Profile name is too long" + } + require(profile.sourceSystemId == null || SAFE_SYSTEM_ID.matches(profile.sourceSystemId)) { + "Profile source system ID is invalid" + } + require(profile.customMounts.size <= MAX_CUSTOM_MOUNTS) { + "A mount profile supports at most $MAX_CUSTOM_MOUNTS custom mappings" + } + val knownDefaults = PROOT_DEFAULT_MOUNTS.mapTo(mutableSetOf(), ProotDefaultMount::id) + require(profile.defaultOverrides.keys.all(knownDefaults::contains)) { + "The profile contains an unknown default mount" + } + require(profile.customMounts.map(ProotCustomMount::id).distinct().size == profile.customMounts.size) { + "Custom mount IDs must be unique" + } + profile.customMounts.forEach { mount -> + require(SAFE_ID.matches(mount.id)) { "Invalid custom mount ID" } + requireSafePath(mount.hostSource, "Host source") + requireSafePath(mount.guestTarget, "Guest target") + require(mount.guestTarget !in RESERVED_GUEST_TARGETS) { + "${mount.guestTarget} is managed by the uDroid runtime" + } + } + + 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 + } + + private fun requireSafePath( + path: String, + label: String, + ) { + require(path.isNotBlank() && path == path.trim()) { "$label must not be blank" } + require(path.startsWith('/')) { "$label must be an absolute path" } + require(path.length <= MAX_PATH_LENGTH) { "$label is too long" } + require('\u0000' !in path && '\n' !in path && '\r' !in path) { + "$label contains unsupported characters" + } + require(':' !in path) { "$label cannot contain ':' because PRoot uses it as a delimiter" } + require(path.split('/').none { it == "." || it == ".." }) { + "$label must not contain '.' or '..' segments" + } + } + + val RESERVED_GUEST_TARGETS = setOf("/tmp/.X11-unix", "/tmp/.udroid-pulse") + + private const val MAX_CUSTOM_MOUNTS = 64 + private const val MAX_PROFILE_NAME_LENGTH = 64 + private const val MAX_PATH_LENGTH = 1024 + private val SAFE_ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,95}") + private val SAFE_SYSTEM_ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,95}") +} + +object ProotMountResolver { + fun resolve( + profile: ProotMountProfile, + sessionMounts: List = emptyList(), + ): List { + ProotMountProfileValidator.requireValid(profile) + val resolved = + buildList { + PROOT_DEFAULT_MOUNTS + .filter { profile.isDefaultEnabled(it.id) } + .forEach { + add( + ResolvedProotMount( + hostSource = it.hostSource, + guestTarget = it.guestTarget, + origin = "default:${it.id}", + ), + ) + } + profile.customMounts.filter(ProotCustomMount::enabled).forEach { + add( + ResolvedProotMount( + hostSource = it.hostSource, + guestTarget = it.guestTarget, + origin = "custom:${it.id}", + ), + ) + } + addAll(sessionMounts) + } + val targets = resolved.map(ResolvedProotMount::guestTarget) + require(targets.distinct().size == targets.size) { + "Resolved mappings contain duplicate guest destinations" + } + return resolved + } + + fun defaults( + x11SocketDirectory: String? = null, + audioAuthDirectory: String? = null, + ): List = + resolve( + profile = ProotMountProfile(), + sessionMounts = sessionMounts(x11SocketDirectory, audioAuthDirectory), + ) + + fun sessionMounts( + x11SocketDirectory: String?, + audioAuthDirectory: String?, + ): List = + buildList { + if (x11SocketDirectory != null) { + add( + ResolvedProotMount( + hostSource = x11SocketDirectory, + guestTarget = "/tmp/.X11-unix", + origin = "runtime:x11", + ), + ) + } + if (audioAuthDirectory != null) { + add( + ResolvedProotMount( + hostSource = audioAuthDirectory, + guestTarget = "/tmp/.udroid-pulse", + origin = "runtime:audio", + ), + ) + } + } +} + +class ProotMountProfileStore(context: Context) { + private val systemsDirectory = File(context.applicationContext.filesDir, "linux-systems") + + @Synchronized + fun systemIds(): List = + systemsDirectory + .listFiles() + .orEmpty() + .asSequence() + .filter(File::isDirectory) + .filter { File(it, PROFILE_FILE_NAME).isFile } + .map(File::getName) + .filter(SAFE_SYSTEM_ID::matches) + .sorted() + .toList() + + @Synchronized + fun load(systemId: String): ProotMountProfile { + val file = profileFile(systemId) + if (!file.isFile) return ProotMountProfile() + return ProotMountProfileCodec.decode(file.readText()) + } + + @Synchronized + fun save( + systemId: String, + profile: ProotMountProfile, + ): ProotMountProfile { + requireSafeSystemId(systemId) + val validated = ProotMountProfileValidator.requireValid(profile) + val target = profileFile(systemId) + check(target.parentFile?.mkdirs() == true || target.parentFile?.isDirectory == true) { + "Could not create mount profile storage for $systemId" + } + val temporary = File(target.parentFile, "${target.name}.tmp") + FileOutputStream(temporary).use { output -> + output.write(ProotMountProfileCodec.encode(validated).toByteArray()) + output.fd.sync() + } + try { + Files.move( + temporary.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + temporary.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + return validated + } + + @Synchronized + fun restoreDefaults(systemId: String): ProotMountProfile = save(systemId, ProotMountProfile()) + + @Synchronized + fun copy( + sourceSystemId: String, + destinationSystemId: String, + ): ProotMountProfile { + val source = load(sourceSystemId) + return save( + destinationSystemId, + source + .copy(sourceSystemId = source.sourceSystemId ?: sourceSystemId) + .independentCopy(), + ) + } + + @Synchronized + fun remove(systemId: String) { + val file = profileFile(systemId) + if (file.exists()) check(file.delete()) { "Could not delete mount profile for $systemId" } + file.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete() + } + + private fun profileFile(systemId: String): File { + requireSafeSystemId(systemId) + return File(File(systemsDirectory, systemId), PROFILE_FILE_NAME) + } + + private fun requireSafeSystemId(systemId: String) { + require(SAFE_SYSTEM_ID.matches(systemId)) { "Unsafe Linux system ID: $systemId" } + } + + private companion object { + const val PROFILE_FILE_NAME = "mounts.json" + val SAFE_SYSTEM_ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,95}") + } +} + +internal object ProotMountProfileCodec { + fun encode(profile: ProotMountProfile): String = + buildJsonObject { + put("format", FORMAT) + put("defaults_revision", DEFAULTS_REVISION) + put("name", profile.name) + profile.sourceSystemId?.let { put("source_system_id", it) } + put( + "default_overrides", + JsonObject(profile.defaultOverrides.mapValues { JsonPrimitive(it.value) }), + ) + put( + "custom_mounts", + JsonArray( + profile.customMounts.map { mount -> + buildJsonObject { + put("id", mount.id) + put("enabled", mount.enabled) + put("host_source", mount.hostSource) + put("guest_target", mount.guestTarget) + } + }, + ), + ) + }.toString() + + fun decode(encoded: String): ProotMountProfile { + require(encoded.length <= MAX_ENCODED_LENGTH) { "Mount profile is too large" } + val value = Json.parseToJsonElement(encoded).jsonObject + require(value.requiredString("format") == FORMAT) { "Unsupported mount profile format" } + val name = value["name"]?.jsonPrimitive?.content ?: "Default profile" + val sourceSystemId = value["source_system_id"]?.jsonPrimitive?.content + val overrides = + value["default_overrides"] + ?.jsonObject + ?.mapValues { (_, enabled) -> enabled.jsonPrimitive.boolean } + .orEmpty() + val customMounts = + value["custom_mounts"] + ?.jsonArray + ?.map { element -> + val mount = element.jsonObject + ProotCustomMount( + id = mount.requiredString("id"), + enabled = mount.getValue("enabled").jsonPrimitive.boolean, + hostSource = mount.requiredString("host_source"), + guestTarget = mount.requiredString("guest_target"), + ) + }.orEmpty() + return ProotMountProfileValidator.requireValid( + ProotMountProfile( + name = name, + sourceSystemId = sourceSystemId, + defaultOverrides = overrides, + customMounts = customMounts, + ), + ) + } + + private fun JsonObject.requiredString(key: String): String = + getValue(key).jsonPrimitive.content + + private const val FORMAT = "1" + private const val DEFAULTS_REVISION = 1 + private const val MAX_ENCODED_LENGTH = 128 * 1024 +} diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt index d03bb93..ec0c8cf 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt @@ -13,6 +13,7 @@ data class ProotTerminalLaunch( val arguments: Array, val environment: Array, val rootfs: File, + val mounts: List, ) object InstalledRootfsResolver { @@ -49,6 +50,15 @@ object ProotTerminalLaunchBuilder { val guestShell = findGuestShell(rootfs) ?: error("The installed Linux image has no supported shell") + val mounts = + ProotMountResolver.resolve( + profile = ProotMountProfileStore(context).load(rootfs.name), + sessionMounts = + ProotMountResolver.sessionMounts( + x11SocketDirectory = x11SocketDirectory?.absolutePath, + audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, + ), + ) val arguments = buildArguments( @@ -59,6 +69,7 @@ object ProotTerminalLaunchBuilder { guestShell = guestShell, x11SocketDirectory = x11SocketDirectory?.absolutePath, audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, + mounts = mounts, ) val environment = buildEnvironment( @@ -73,6 +84,7 @@ object ProotTerminalLaunchBuilder { arguments = arguments, environment = environment, rootfs = rootfs, + mounts = mounts, ) } @@ -84,6 +96,8 @@ object ProotTerminalLaunchBuilder { guestShell: String, x11SocketDirectory: String? = null, audioAuthDirectory: String? = null, + mounts: List = + ProotMountResolver.defaults(x11SocketDirectory, audioAuthDirectory), ): Array = buildList { // TerminalSession passes this complete vector to execvp(), including argv[0]. @@ -93,15 +107,7 @@ object ProotTerminalLaunchBuilder { add("--kill-on-exit") add("--root-id") add("--rootfs=$rootfsPath") - addAndroidProotBindMounts() - if (x11SocketDirectory != null) { - add("-b") - add("$x11SocketDirectory:/tmp/.X11-unix") - } - if (audioAuthDirectory != null) { - add("-b") - add("$audioAuthDirectory:${AudioEndpoint.GUEST_AUTH_DIRECTORY}") - } + addProotBindMounts(mounts) add("--cwd=$guestHome") add("/usr/bin/env") add("-i") 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 e8567d5..fd8b3a1 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt @@ -477,6 +477,7 @@ class RuntimeSupervisorService : Service() { mapOf( "desktop_id" to application.id, "executable" to application.executable, + "mounts" to launch.mounts.joinToString { it.argument }, ), ) } @@ -626,6 +627,7 @@ class RuntimeSupervisorService : Service() { pidFile = pidFile, rootfsName = request.rootfsName, environment = request.environment, + mounts = launch.mounts, ) check(ownedDesktop.compareAndSet(null, owned)) { "Another desktop session won display :0" @@ -666,6 +668,7 @@ class RuntimeSupervisorService : Service() { mapOf( "rootfs" to owned.rootfsName, "display" to DISPLAY_NUMBER, + "mounts" to owned.mounts.joinToString { it.argument }, ), ) monitorDesktop(owned) @@ -1038,6 +1041,7 @@ class RuntimeSupervisorService : Service() { "pid" to session.pid, "rootfs" to launch.rootfs.name, "terminal" to "termux-v0.118.3", + "mounts" to launch.mounts.joinToString { it.argument }, ), ) }.onFailure { error -> @@ -1465,5 +1469,6 @@ class RuntimeSupervisorService : Service() { val pidFile: File, val rootfsName: String, val environment: DesktopEnvironment, + val mounts: List, ) } diff --git a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt index b1bf270..0bea512 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt @@ -45,6 +45,7 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.Code @@ -60,6 +61,7 @@ import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.SystemUpdateAlt import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.Button import androidx.compose.material3.Divider import androidx.compose.material3.Icon @@ -75,6 +77,11 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -89,6 +96,7 @@ import org.json.JSONObject import org.randomcoder.udroid.BuildConfig import org.randomcoder.udroid.catalog.DistroCatalogState import org.randomcoder.udroid.catalog.DistroVariant +import org.randomcoder.udroid.catalog.LinuxDistribution import org.randomcoder.udroid.audio.AudioConfiguration import org.randomcoder.udroid.install.InstallProgress import org.randomcoder.udroid.linuxapps.LinuxApplication @@ -101,7 +109,10 @@ import org.randomcoder.udroid.runtime.CapabilityResult import org.randomcoder.udroid.runtime.CapabilityStatus import org.randomcoder.udroid.runtime.DesktopConfiguration import org.randomcoder.udroid.runtime.DesktopEnvironment +import org.randomcoder.udroid.runtime.DesktopSessionPhase import org.randomcoder.udroid.runtime.InstalledRootfs +import org.randomcoder.udroid.runtime.ProotMountProfile +import org.randomcoder.udroid.runtime.ProotMountProfileStore import org.randomcoder.udroid.runtime.RuntimePhase import org.randomcoder.udroid.runtime.RuntimeSnapshot import org.randomcoder.udroid.runtime.RuntimeSupervisorService @@ -118,6 +129,8 @@ enum class UdroidDestination( DISTROS("Linux", Icons.Outlined.Storage, Icons.Filled.Storage), INSTALL("Install", Icons.Outlined.Storage, Icons.Filled.Storage), SYSTEM("System", Icons.Outlined.Storage, Icons.Filled.Storage), + MOUNTS("Mounts", Icons.Outlined.Tune, Icons.Filled.Tune), + MOUNT_EDITOR("Mounts", Icons.Outlined.Tune, Icons.Filled.Tune), TERMINAL("Terminal", Icons.Outlined.Terminal, Icons.Filled.Terminal), APPS("Apps", Icons.Outlined.Apps, Icons.Filled.Apps), DESKTOP("Desktop", Icons.Outlined.DesktopWindows, Icons.Filled.DesktopWindows), @@ -150,6 +163,8 @@ private val UdroidDestination.navigationDepth: Int UdroidDestination.INSTALL, UdroidDestination.SYSTEM, -> 1 + UdroidDestination.MOUNTS -> 2 + UdroidDestination.MOUNT_EDITOR -> 3 else -> 0 } @@ -196,6 +211,7 @@ fun UdroidApp( onOpenRootfsTerminal: (String) -> Unit, onOpenRootfsApps: (String) -> Unit, onResetRootfs: (String, DistroVariant?) -> Unit, + onCreateRootfsVariation: (String, DistroVariant?, ProotMountProfile) -> Unit, onDeleteRootfs: (String) -> Unit, onSelectDesktopEnvironment: (String) -> Unit, onCompositingChanged: (Boolean) -> Unit, @@ -219,6 +235,8 @@ fun UdroidApp( onInstallUpdate: () -> Unit, onOpenUpdateRelease: () -> Unit, ) { + var mountConfigurationSourceSystemId by rememberSaveable { mutableStateOf(null) } + var selectedMountProfileSystemId by rememberSaveable { mutableStateOf(null) } val hasInstalledLinux = installedRootfsName != null val requestedJourney = workspaceJourney( @@ -233,6 +251,9 @@ fun UdroidApp( UdroidDestination.INSTALL, UdroidDestination.SYSTEM, -> UdroidDestination.DISTROS + UdroidDestination.MOUNTS, + UdroidDestination.MOUNT_EDITOR, + -> UdroidDestination.DISTROS else -> activeDestination } @@ -319,6 +340,8 @@ fun UdroidApp( linuxApplicationsState = linuxApplicationsState, linuxApplicationMessage = linuxApplicationMessage, showInstallTerminal = showInstallTerminal, + mountConfigurationSourceSystemId = mountConfigurationSourceSystemId, + selectedMountProfileSystemId = selectedMountProfileSystemId, onDestinationSelected = onDestinationSelected, onPrimaryDestinationSelected = onPrimaryDestinationSelected, onStart = onStart, @@ -333,7 +356,21 @@ fun UdroidApp( onOpenInstalledSystem = onOpenInstalledSystem, onOpenRootfsTerminal = onOpenRootfsTerminal, onOpenRootfsApps = onOpenRootfsApps, + onSelectMountProfile = { systemId -> + mountConfigurationSourceSystemId = systemId + selectedMountProfileSystemId = null + onDestinationSelected(UdroidDestination.MOUNTS) + }, + onCreateMountProfile = { + selectedMountProfileSystemId = null + onDestinationSelected(UdroidDestination.MOUNT_EDITOR) + }, + onEditMountProfile = { systemId -> + selectedMountProfileSystemId = systemId + onDestinationSelected(UdroidDestination.MOUNT_EDITOR) + }, onResetRootfs = onResetRootfs, + onCreateRootfsVariation = onCreateRootfsVariation, onDeleteRootfs = onDeleteRootfs, onSelectDesktopEnvironment = onSelectDesktopEnvironment, onCompositingChanged = onCompositingChanged, @@ -387,6 +424,8 @@ fun UdroidApp( linuxApplicationsState = linuxApplicationsState, linuxApplicationMessage = linuxApplicationMessage, showInstallTerminal = showInstallTerminal, + mountConfigurationSourceSystemId = mountConfigurationSourceSystemId, + selectedMountProfileSystemId = selectedMountProfileSystemId, onDestinationSelected = onDestinationSelected, onPrimaryDestinationSelected = onPrimaryDestinationSelected, onStart = onStart, @@ -401,7 +440,21 @@ fun UdroidApp( onOpenInstalledSystem = onOpenInstalledSystem, onOpenRootfsTerminal = onOpenRootfsTerminal, onOpenRootfsApps = onOpenRootfsApps, + onSelectMountProfile = { systemId -> + mountConfigurationSourceSystemId = systemId + selectedMountProfileSystemId = null + onDestinationSelected(UdroidDestination.MOUNTS) + }, + onCreateMountProfile = { + selectedMountProfileSystemId = null + onDestinationSelected(UdroidDestination.MOUNT_EDITOR) + }, + onEditMountProfile = { systemId -> + selectedMountProfileSystemId = systemId + onDestinationSelected(UdroidDestination.MOUNT_EDITOR) + }, onResetRootfs = onResetRootfs, + onCreateRootfsVariation = onCreateRootfsVariation, onDeleteRootfs = onDeleteRootfs, onSelectDesktopEnvironment = onSelectDesktopEnvironment, onCompositingChanged = onCompositingChanged, @@ -469,6 +522,8 @@ private fun ManagementPane( linuxApplicationsState: LinuxApplicationsState, linuxApplicationMessage: String?, showInstallTerminal: Boolean, + mountConfigurationSourceSystemId: String?, + selectedMountProfileSystemId: String?, onDestinationSelected: (UdroidDestination) -> Unit, onPrimaryDestinationSelected: (UdroidDestination) -> Unit, onStart: () -> Unit, @@ -483,7 +538,11 @@ private fun ManagementPane( onOpenInstalledSystem: (String) -> Unit, onOpenRootfsTerminal: (String) -> Unit, onOpenRootfsApps: (String) -> Unit, + onSelectMountProfile: (String) -> Unit, + onCreateMountProfile: () -> Unit, + onEditMountProfile: (String) -> Unit, onResetRootfs: (String, DistroVariant?) -> Unit, + onCreateRootfsVariation: (String, DistroVariant?, ProotMountProfile) -> Unit, onDeleteRootfs: (String) -> Unit, onSelectDesktopEnvironment: (String) -> Unit, onCompositingChanged: (Boolean) -> Unit, @@ -626,6 +685,115 @@ private fun ManagementPane( }, ) } + UdroidDestination.MOUNTS -> { + val sourceSystemId = mountConfigurationSourceSystemId + if (sourceSystemId == null) { + onDestinationSelected(UdroidDestination.DISTROS) + } else { + val sourceRootfs = + installedRootfses.firstOrNull { it.name == sourceSystemId } + val sourceDistro = + (catalogueState as? DistroCatalogState.Ready) + ?.catalog + ?.variants + ?.firstOrNull { it.internalName == sourceSystemId } + if (sourceRootfs == null) { + onDestinationSelected(UdroidDestination.DISTROS) + } else { + ProotMountConfigurationsPage( + sourceSystemId = sourceSystemId, + sourceSystemTitle = + sourceDistro?.releaseName + ?: installedSystemTitle(sourceSystemId), + distribution = + sourceDistro?.distribution + ?: distributionFromSystemId(sourceSystemId), + installedRootfses = installedRootfses, + activeRootfsName = installedRootfsName, + installProgress = installProgress, + onBack = { + onDestinationSelected(UdroidDestination.SYSTEM) + }, + onCreateConfiguration = { + onCreateMountProfile() + }, + onEditConfiguration = { configurationSystemId -> + onEditMountProfile(configurationSystemId) + }, + onLaunchDistro = onOpenInstalledSystem, + onDeleteConfiguration = onDeleteRootfs, + ) + } + } + } + UdroidDestination.MOUNT_EDITOR -> { + val sourceSystemId = mountConfigurationSourceSystemId + if (sourceSystemId == null) { + onDestinationSelected(UdroidDestination.DISTROS) + } else { + val configurationSystemId = selectedMountProfileSystemId + val targetSystemId = configurationSystemId ?: sourceSystemId + val sourceDistro = + (catalogueState as? DistroCatalogState.Ready) + ?.catalog + ?.variants + ?.firstOrNull { it.internalName == sourceSystemId } + val runtimeBusy = + snapshot.rootfsName == targetSystemId && + snapshot.phase in + setOf( + RuntimePhase.STARTING, + RuntimePhase.RUNNING, + RuntimePhase.STOPPING, + ) + val desktopBusy = + snapshot.desktop.rootfsName == targetSystemId && + snapshot.desktop.phase in + setOf( + DesktopSessionPhase.STARTING, + DesktopSessionPhase.RUNNING, + DesktopSessionPhase.STOPPING, + ) + ProotMountConfigurationEditorPage( + sourceSystemId = sourceSystemId, + configurationSystemId = configurationSystemId, + systemTitle = + if (configurationSystemId == null) { + sourceDistro?.releaseName + ?: installedSystemTitle(sourceSystemId) + } else { + installProgress + ?.takeIf { + it.installationName == configurationSystemId + }?.displayName + ?: installedSystemTitle(configurationSystemId) + }, + distribution = + sourceDistro?.distribution + ?: distributionFromSystemId(sourceSystemId), + active = configurationSystemId == installedRootfsName, + editingEnabled = + if (configurationSystemId == null) { + installProgress == null + } else { + !runtimeBusy && + !desktopBusy && + rootfsMaintenanceName != targetSystemId + }, + externalMessage = rootfsMaintenanceMessage, + onBack = { + onDestinationSelected(UdroidDestination.MOUNTS) + }, + onCreateDistro = { profile -> + onCreateRootfsVariation( + sourceSystemId, + sourceDistro, + profile, + ) + }, + ) + } + } UdroidDestination.DISTROS -> selectedOciRepository?.let { repository -> OciTagCataloguePage( @@ -673,6 +841,16 @@ private fun ManagementPane( if (selectedRootfs == null) { onDestinationSelected(UdroidDestination.DISTROS) } else { + val context = LocalContext.current + val mountProfileStore = remember(context) { + ProotMountProfileStore(context) + } + val mountConfigurationSourceId = + remember(selectedRootfs.name) { + runCatching { + mountProfileStore.load(selectedRootfs.name).sourceSystemId + }.getOrNull() ?: selectedRootfs.name + } LinuxSystemPage( rootfs = selectedRootfs, distro = selectedDistro, @@ -716,6 +894,9 @@ private fun ManagementPane( onStopTerminal = onStop, onStopDesktop = onStopDesktop, onRestartDesktop = onRestartDesktop, + onConfigureMounts = { + onSelectMountProfile(mountConfigurationSourceId) + }, onResetFilesystem = { onResetRootfs(selectedRootfs.name, selectedDistro) }, @@ -1591,6 +1772,17 @@ private fun installedSystemTitle(rootfsName: String): String = else -> rootfsName } +private fun distributionFromSystemId(systemId: String): LinuxDistribution { + val normalized = systemId.lowercase() + return when { + "debian" in normalized -> LinuxDistribution.DEBIAN + "arch" in normalized -> LinuxDistribution.ARCH + "alpine" in normalized -> LinuxDistribution.ALPINE + "void" in normalized -> LinuxDistribution.VOID + else -> LinuxDistribution.UBUNTU + } +} + private const val GITHUB_REPOSITORY_URL = "https://github.com/RandomCoderOrg/udroid-app" private const val GITHUB_SPONSOR_URL = "https://github.com/sponsors/RandomCoderOrg" private const val GITHUB_ISSUES_URL = diff --git a/app/src/main/java/org/randomcoder/udroid/ui/DistroScreens.kt b/app/src/main/java/org/randomcoder/udroid/ui/DistroScreens.kt index f60b863..385b471 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/DistroScreens.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/DistroScreens.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -37,11 +38,13 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -59,7 +62,13 @@ import org.randomcoder.udroid.oci.OciHubTagPlatform import org.randomcoder.udroid.oci.OciHubTagsState import org.randomcoder.udroid.oci.OciPlatform import org.randomcoder.udroid.runtime.InstalledRootfs +import org.randomcoder.udroid.runtime.PROOT_DEFAULT_MOUNTS +import org.randomcoder.udroid.runtime.ProotMountProfile +import org.randomcoder.udroid.runtime.ProotMountProfileStore import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -855,6 +864,19 @@ fun InstallExperiencePage( onRetryDownload: () -> Unit, ) { BackHandler(onBack = onBack) + val context = LocalContext.current + val mountProfileStore = remember(context) { ProotMountProfileStore(context) } + val scope = rememberCoroutineScope() + var showMountProfile by remember(progress.installationName) { mutableStateOf(false) } + var mountProfile by remember(progress.installationName) { + mutableStateOf( + runCatching { mountProfileStore.load(progress.installationName) } + .getOrDefault(ProotMountProfile()), + ) + } + var mountProfileMessage by remember(progress.installationName) { + mutableStateOf(null) + } Box(modifier = Modifier.fillMaxSize()) { LazyColumn( modifier = @@ -947,6 +969,56 @@ fun InstallExperiencePage( } } + item { + val enabledDefaults = + PROOT_DEFAULT_MOUNTS.count { mountProfile.isDefaultEnabled(it.id) } + val enabledCustom = mountProfile.customMounts.count { it.enabled } + Surface( + color = UdroidSurface, + border = BorderStroke(1.dp, UdroidLine), + shape = RoundedCornerShape(12.dp), + ) { + Column(modifier = Modifier.padding(14.dp)) { + Text( + "Mount profile", + style = MaterialTheme.typography.titleMedium, + ) + Text( + "$enabledDefaults of ${PROOT_DEFAULT_MOUNTS.size} defaults · " + + "$enabledCustom custom", + modifier = Modifier.padding(top = 3.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + Text( + if (progress.cancellable) { + "The saved profile is locked while installation is running." + } else { + "This profile belongs only to ${progress.installationName}." + }, + modifier = Modifier.padding(top = 3.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + mountProfileMessage?.let { + Text( + it, + modifier = Modifier.padding(top = 6.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + OutlinedButton( + modifier = Modifier.padding(top = 10.dp), + enabled = !progress.cancellable, + onClick = { showMountProfile = true }, + ) { + Text("Configure mounts") + } + } + } + } + item { when { progress.stage == InstallStage.READY -> { @@ -1208,6 +1280,35 @@ fun InstallExperiencePage( } } } + + if (showMountProfile) { + ProotMountProfileDialog( + systemName = progress.displayName, + initialProfile = mountProfile, + onDismiss = { showMountProfile = false }, + onSave = { profile -> + scope.launch { + val saved = + runCatching { + withContext(Dispatchers.IO) { + mountProfileStore.save(progress.installationName, profile) + } + } + saved.fold( + onSuccess = { + mountProfile = it + mountProfileMessage = "Profile saved for this distro." + showMountProfile = false + }, + onFailure = { + mountProfileMessage = + it.message ?: "The mount profile could not be saved" + }, + ) + } + }, + ) + } } } 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 f13e260..2a0fe15 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.RestartAlt +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Stop import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material3.AlertDialog @@ -61,6 +62,9 @@ import org.randomcoder.udroid.runtime.DesktopConfiguration import org.randomcoder.udroid.runtime.DesktopEnvironment import org.randomcoder.udroid.runtime.DesktopSessionPhase import org.randomcoder.udroid.runtime.InstalledRootfs +import org.randomcoder.udroid.runtime.PROOT_DEFAULT_MOUNTS +import org.randomcoder.udroid.runtime.ProotMountProfile +import org.randomcoder.udroid.runtime.ProotMountProfileStore import org.randomcoder.udroid.runtime.RuntimePhase import org.randomcoder.udroid.runtime.RuntimeSnapshot import java.text.DateFormat @@ -94,13 +98,20 @@ fun LinuxSystemPage( onStopTerminal: () -> Unit, onStopDesktop: () -> Unit, onRestartDesktop: () -> Unit, + onConfigureMounts: () -> Unit, onResetFilesystem: () -> Unit, onDeleteFilesystem: () -> Unit, ) { BackHandler(onBack = onBack) + val context = androidx.compose.ui.platform.LocalContext.current + val mountProfileStore = remember(context) { ProotMountProfileStore(context) } var confirmation by remember(rootfs.name) { mutableStateOf(null) } + val mountProfile = remember(rootfs.name) { + runCatching { mountProfileStore.load(rootfs.name) } + .getOrDefault(ProotMountProfile()) + } val selectedEnvironment = environments.firstOrNull { it.id == configuration.environmentId } ?: environments.firstOrNull() @@ -388,6 +399,25 @@ fun LinuxSystemPage( } } + item(key = "mounts-label") { + UdroidSectionLabel( + text = "Mount mappings", + modifier = Modifier.padding(top = 4.dp), + ) + } + item(key = "mounts-settings") { + MountProfilePanel( + profile = mountProfile, + enabled = maintenanceEnabled, + crashed = + snapshot.rootfsName == rootfs.name && + snapshot.phase == RuntimePhase.CRASHED, + message = null, + onConfigure = onConfigureMounts, + onRetry = onOpenTerminal, + ) + } + item(key = "filesystem-label") { UdroidSectionLabel( text = "Filesystem", @@ -567,6 +597,7 @@ fun LinuxSystemPage( }, ) } + } private enum class FilesystemConfirmation { @@ -574,6 +605,90 @@ private enum class FilesystemConfirmation { DELETE, } +@Composable +private fun MountProfilePanel( + profile: ProotMountProfile, + enabled: Boolean, + crashed: Boolean, + message: String?, + onConfigure: () -> Unit, + onRetry: () -> Unit, +) { + val enabledDefaults = PROOT_DEFAULT_MOUNTS.count { profile.isDefaultEnabled(it.id) } + val enabledCustom = profile.customMounts.count { it.enabled } + Surface( + color = Color.Transparent, + border = BorderStroke(1.dp, UdroidLine), + shape = RoundedCornerShape(12.dp), + ) { + Column(modifier = Modifier.padding(14.dp)) { + Text( + "$enabledDefaults of ${PROOT_DEFAULT_MOUNTS.size} defaults enabled", + style = MaterialTheme.typography.titleSmall, + ) + Text( + if (enabledCustom == 1) { + "1 enabled custom mapping" + } else { + "$enabledCustom enabled custom mappings" + }, + modifier = Modifier.padding(top = 3.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + if (!enabled) { + Text( + "Stop this Linux system before changing its launch profile.", + modifier = Modifier.padding(top = 8.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + if (crashed) { + Text( + "The last runtime exited unexpectedly. The saved profile was not changed.", + modifier = Modifier.padding(top = 8.dp), + color = UdroidWarning, + style = MaterialTheme.typography.bodySmall, + ) + } + message?.let { + Text( + it, + modifier = Modifier.padding(top = 8.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + enabled = enabled, + onClick = onConfigure, + ) { + Icon(Icons.Outlined.Settings, contentDescription = null) + Text("Configure mounts", modifier = Modifier.padding(start = 6.dp)) + } + if (crashed) { + Button( + modifier = Modifier.weight(1f), + onClick = onRetry, + ) { + Icon(Icons.Outlined.Refresh, contentDescription = null) + Text("Retry", modifier = Modifier.padding(start = 6.dp)) + } + } + } + } + } +} + @Composable private fun AudioSettingsPanel( configuration: AudioConfiguration, diff --git a/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfileDialog.kt b/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfileDialog.kt new file mode 100644 index 0000000..ee32d69 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfileDialog.kt @@ -0,0 +1,314 @@ +package org.randomcoder.udroid.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.RestartAlt +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +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.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 +import androidx.compose.ui.unit.dp +import org.randomcoder.udroid.runtime.PROOT_DEFAULT_MOUNTS +import org.randomcoder.udroid.runtime.ProotCustomMount +import org.randomcoder.udroid.runtime.ProotMountProfile +import org.randomcoder.udroid.runtime.ProotMountProfileValidator + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun ProotMountProfileDialog( + systemName: String, + initialProfile: ProotMountProfile, + onDismiss: () -> Unit, + onSave: (ProotMountProfile) -> Unit, +) { + var draft by remember(systemName, initialProfile) { mutableStateOf(initialProfile) } + var validationMessage by remember(systemName) { mutableStateOf(null) } + val disabledDefaults = PROOT_DEFAULT_MOUNTS.count { !draft.isDefaultEnabled(it.id) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Column { + Text("Mount mappings") + Text( + systemName, + color = UdroidMuted, + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + }, + text = { + Column( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 560.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + color = UdroidWarningSurface, + shape = RoundedCornerShape(10.dp), + ) { + Text( + "This profile is applied exactly as saved. Disabling system paths " + + "may prevent Linux from starting; uDroid will keep the profile " + + "and report the crash.", + modifier = Modifier.padding(12.dp), + color = UdroidWarning, + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + + Column { + Text( + "uDroid defaults", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + ) + Text( + "${PROOT_DEFAULT_MOUNTS.size - disabledDefaults} of " + + "${PROOT_DEFAULT_MOUNTS.size} enabled", + color = UdroidMuted, + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + + Surface( + color = androidx.compose.ui.graphics.Color.Transparent, + border = BorderStroke(1.dp, UdroidLine), + shape = RoundedCornerShape(11.dp), + ) { + Column { + PROOT_DEFAULT_MOUNTS.forEachIndexed { index, mount -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + mount.guestTarget, + fontFamily = FontFamily.Monospace, + style = + androidx.compose.material3.MaterialTheme.typography + .bodyMedium, + ) + Text( + mount.label, + color = UdroidMuted, + style = + androidx.compose.material3.MaterialTheme.typography + .bodySmall, + ) + } + Switch( + checked = draft.isDefaultEnabled(mount.id), + onCheckedChange = { enabled -> + draft = draft.withDefaultEnabled(mount.id, enabled) + validationMessage = null + }, + ) + } + if (index != PROOT_DEFAULT_MOUNTS.lastIndex) { + Divider(color = UdroidLine) + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Custom mappings", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + ) + Text( + "Absolute host path to absolute guest path", + color = UdroidMuted, + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + TextButton( + onClick = { + draft = + draft.copy( + customMounts = + draft.customMounts + + ProotCustomMount( + hostSource = "", + guestTarget = "", + ), + ) + validationMessage = null + }, + ) { + Icon(Icons.Outlined.Add, contentDescription = null) + Text("Add") + } + } + + draft.customMounts.forEach { mount -> + Surface( + color = UdroidInset, + shape = RoundedCornerShape(11.dp), + ) { + Column(modifier = Modifier.padding(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Mapping", + modifier = Modifier.weight(1f), + style = + androidx.compose.material3.MaterialTheme.typography + .labelLarge, + ) + Switch( + checked = mount.enabled, + onCheckedChange = { enabled -> + draft = + draft.copy( + customMounts = + draft.customMounts.map { + if (it.id == mount.id) { + it.copy(enabled = enabled) + } else { + it + } + }, + ) + validationMessage = null + }, + ) + IconButton( + onClick = { + draft = + draft.copy( + customMounts = + draft.customMounts.filterNot { + it.id == mount.id + }, + ) + validationMessage = null + }, + ) { + Icon( + Icons.Outlined.DeleteOutline, + contentDescription = "Delete mapping", + tint = androidx.compose.material3.MaterialTheme.colorScheme.error, + ) + } + } + OutlinedTextField( + value = mount.hostSource, + onValueChange = { value -> + draft = draft.updateMount(mount.id) { it.copy(hostSource = value) } + validationMessage = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Host source") }, + placeholder = { Text("/storage/emulated/0/Projects") }, + singleLine = true, + textStyle = + androidx.compose.material3.MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = mount.guestTarget, + onValueChange = { value -> + draft = draft.updateMount(mount.id) { it.copy(guestTarget = value) } + validationMessage = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Guest destination") }, + placeholder = { Text("/workspace") }, + singleLine = true, + textStyle = + androidx.compose.material3.MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + } + } + + TextButton( + onClick = { + draft = ProotMountProfile() + validationMessage = null + }, + ) { + Icon(Icons.Outlined.RestartAlt, contentDescription = null) + Text("Restore uDroid defaults") + } + + validationMessage?.let { + Text( + it, + color = androidx.compose.material3.MaterialTheme.colorScheme.error, + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + confirmButton = { + Button( + onClick = { + runCatching { ProotMountProfileValidator.requireValid(draft) } + .onSuccess(onSave) + .onFailure { + validationMessage = it.message ?: "The mount profile is invalid" + } + }, + ) { + Text("Save profile") + } + }, + ) +} + +private fun ProotMountProfile.updateMount( + id: String, + update: (ProotCustomMount) -> ProotCustomMount, +): ProotMountProfile = + copy( + customMounts = customMounts.map { if (it.id == id) update(it) else it }, + ) diff --git a/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfilesPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfilesPage.kt new file mode 100644 index 0000000..b33bc44 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/ui/ProotMountProfilesPage.kt @@ -0,0 +1,784 @@ +package org.randomcoder.udroid.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.RestartAlt +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Divider +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.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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.randomcoder.udroid.catalog.LinuxDistribution +import org.randomcoder.udroid.install.InstallProgress +import org.randomcoder.udroid.runtime.InstalledRootfs +import org.randomcoder.udroid.runtime.PROOT_DEFAULT_MOUNTS +import org.randomcoder.udroid.runtime.ProotCustomMount +import org.randomcoder.udroid.runtime.ProotMountProfile +import org.randomcoder.udroid.runtime.ProotMountProfileStore +import org.randomcoder.udroid.runtime.ProotMountProfileValidator + +private data class MountConfigurationItem( + val systemId: String, + val profile: ProotMountProfile, + val installed: Boolean, + val active: Boolean, + val setupInProgress: Boolean, +) + +@Composable +fun ProotMountConfigurationsPage( + sourceSystemId: String, + sourceSystemTitle: String, + distribution: LinuxDistribution, + installedRootfses: List, + activeRootfsName: String?, + installProgress: InstallProgress?, + onBack: () -> Unit, + onCreateConfiguration: () -> Unit, + onEditConfiguration: (String) -> Unit, + onLaunchDistro: (String) -> Unit, + onDeleteConfiguration: (String) -> Unit, +) { + BackHandler(onBack = onBack) + val context = LocalContext.current + val store = remember(context) { ProotMountProfileStore(context) } + val installedIds = installedRootfses.mapTo(mutableSetOf(), InstalledRootfs::name) + val storedIds = runCatching(store::systemIds).getOrDefault(emptyList()) + val configurationIds = + linkedSetOf(sourceSystemId).apply { + storedIds.forEach { systemId -> + val profile = runCatching { store.load(systemId) }.getOrNull() + if (profile?.sourceSystemId == sourceSystemId) add(systemId) + } + installProgress + ?.takeIf { progress -> + runCatching { store.load(progress.installationName).sourceSystemId } + .getOrNull() == sourceSystemId + }?.installationName + ?.let(::add) + } + val configurations = + configurationIds + .map { systemId -> + val loaded = runCatching { store.load(systemId) }.getOrDefault(ProotMountProfile()) + MountConfigurationItem( + systemId = systemId, + profile = + loaded.copy( + sourceSystemId = loaded.sourceSystemId ?: sourceSystemId, + ), + installed = systemId in installedIds, + active = systemId == activeRootfsName, + setupInProgress = installProgress?.installationName == systemId, + ) + }.sortedWith( + compareByDescending { it.systemId == sourceSystemId } + .thenByDescending { it.active } + .thenBy { it.profile.name.lowercase() }, + ) + var pendingDelete by remember(sourceSystemId) { + mutableStateOf(null) + } + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item(key = "configuration-list-header") { + Row( + modifier = Modifier.padding(top = 10.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back to distro", + ) + } + Column(modifier = Modifier.weight(1f).padding(start = 6.dp)) { + Text("Mount configurations", style = MaterialTheme.typography.headlineSmall) + Text( + sourceSystemTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = UdroidMuted, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + item(key = "configuration-source") { + Surface( + color = UdroidRaised, + border = BorderStroke(1.dp, UdroidLine), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DistroMark(distribution = distribution, size = 44) + Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text("Source distro", color = UdroidMuted, style = MaterialTheme.typography.labelMedium) + Text(sourceSystemTitle, style = MaterialTheme.typography.titleMedium) + Text( + sourceSystemId, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = UdroidMuted, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + + item(key = "create-configuration") { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onCreateConfiguration, + shape = RoundedCornerShape(10.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text("Create configuration") + } + } + + item(key = "configuration-list-label") { + UdroidSectionLabel( + text = "Configurations", + modifier = Modifier.padding(top = 6.dp), + ) + } + + items(configurations, key = MountConfigurationItem::systemId) { configuration -> + MountConfigurationCard( + configuration = configuration, + isSource = configuration.systemId == sourceSystemId, + onLaunch = { onLaunchDistro(configuration.systemId) }, + onEdit = { onEditConfiguration(configuration.systemId) }, + onDelete = { pendingDelete = configuration }, + ) + } + + item { Spacer(Modifier.height(24.dp)) } + } + + pendingDelete?.let { configuration -> + AlertDialog( + onDismissRequest = { pendingDelete = null }, + title = { Text("Delete ${configuration.profile.name}?") }, + text = { + Text( + "This removes the configuration and its attached distro filesystem. " + + "This cannot be undone.", + ) + }, + dismissButton = { + TextButton(onClick = { pendingDelete = null }) { Text("Cancel") } + }, + confirmButton = { + TextButton( + onClick = { + pendingDelete = null + onDeleteConfiguration(configuration.systemId) + }, + ) { + Text("Delete") + } + }, + ) + } +} + +@Composable +private fun MountConfigurationCard( + configuration: MountConfigurationItem, + isSource: Boolean, + onLaunch: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val enabledDefaults = + PROOT_DEFAULT_MOUNTS.count { configuration.profile.isDefaultEnabled(it.id) } + val enabledCustom = configuration.profile.customMounts.count(ProotCustomMount::enabled) + Surface( + modifier = Modifier.fillMaxWidth().clickable(onClick = onEdit), + color = UdroidRaised, + border = BorderStroke(1.dp, UdroidLine), + shape = RoundedCornerShape(12.dp), + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(configuration.profile.name, style = MaterialTheme.typography.titleMedium) + Text( + configuration.systemId, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = UdroidMuted, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + ) + } + UdroidStatusBadge( + label = + when { + configuration.active -> "Active" + configuration.installed -> "Ready" + configuration.setupInProgress -> "Creating" + else -> "Saved" + }, + color = if (configuration.setupInProgress) UdroidWarning else UdroidForest, + background = + if (configuration.setupInProgress) { + UdroidWarningSurface + } else { + UdroidSoftGreen + }, + ) + } + Text( + "$enabledDefaults of ${PROOT_DEFAULT_MOUNTS.size} defaults · $enabledCustom custom", + modifier = Modifier.padding(top = 9.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + enabled = configuration.installed, + onClick = onLaunch, + shape = RoundedCornerShape(9.dp), + ) { + Icon(Icons.Outlined.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Open distro") + } + OutlinedButton(onClick = onEdit, shape = RoundedCornerShape(9.dp)) { + Icon(Icons.Outlined.Edit, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Edit") + } + if (!isSource && configuration.installed) { + IconButton(onClick = onDelete) { + Icon( + Icons.Outlined.DeleteOutline, + contentDescription = "Delete configuration", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun ProotMountConfigurationEditorPage( + sourceSystemId: String, + configurationSystemId: String?, + systemTitle: String, + distribution: LinuxDistribution, + active: Boolean, + editingEnabled: Boolean, + externalMessage: String?, + onBack: () -> Unit, + onCreateDistro: (ProotMountProfile) -> Unit, +) { + val context = LocalContext.current + val store = remember(context) { ProotMountProfileStore(context) } + val creating = configurationSystemId == null + val initialProfile = + remember(sourceSystemId, configurationSystemId) { + if (configurationSystemId == null) { + runCatching { store.load(sourceSystemId) } + .getOrDefault(ProotMountProfile()) + .independentCopy() + .copy(name = "", sourceSystemId = sourceSystemId) + } else { + runCatching { store.load(configurationSystemId) } + .getOrDefault(ProotMountProfile()) + .copy(sourceSystemId = sourceSystemId) + } + } + var persistedProfile by remember(sourceSystemId, configurationSystemId) { + mutableStateOf(initialProfile) + } + var draft by remember(sourceSystemId, configurationSystemId) { mutableStateOf(initialProfile) } + var message by remember(sourceSystemId, configurationSystemId) { mutableStateOf(null) } + var saving by remember(sourceSystemId, configurationSystemId) { mutableStateOf(false) } + var confirmDiscard by remember(sourceSystemId, configurationSystemId) { + mutableStateOf(false) + } + val scope = rememberCoroutineScope() + val dirty = draft != persistedProfile + + fun requestBack() { + if (dirty) confirmDiscard = true else onBack() + } + + fun submit() { + if (!editingEnabled || saving) return + val validated = + runCatching { + ProotMountProfileValidator.requireValid( + draft.copy(sourceSystemId = sourceSystemId), + ) + }.onFailure { message = it.message ?: "The configuration is invalid" } + .getOrNull() ?: return + saving = true + if (creating) { + persistedProfile = validated + draft = validated + message = "Configuration ready. Preparing the attached distro…" + saving = false + onCreateDistro(validated) + } else { + scope.launch { + val result = + runCatching { + withContext(Dispatchers.IO) { + store.save(configurationSystemId, validated) + } + } + saving = false + result.fold( + onSuccess = { saved -> + persistedProfile = saved + draft = saved + message = "Configuration saved. It applies on the next launch." + }, + onFailure = { error -> + message = error.message ?: "The configuration could not be saved" + }, + ) + } + } + } + + BackHandler(onBack = ::requestBack) + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "configuration-editor-header") { + Row( + modifier = Modifier.padding(top = 10.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = ::requestBack) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back to configurations", + ) + } + Column(modifier = Modifier.weight(1f).padding(start = 6.dp)) { + Text( + if (creating) "Create configuration" else "Edit configuration", + style = MaterialTheme.typography.headlineSmall, + ) + Text( + systemTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = UdroidMuted, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + item(key = "configuration-attached-distro-label") { + UdroidSectionLabel(text = if (creating) "Source distro" else "Attached distro") + } + + item(key = "configuration-attached-distro") { + Surface( + color = UdroidRaised, + border = BorderStroke(1.dp, UdroidLine), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DistroMark(distribution = distribution, size = 44) + Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text(systemTitle, style = MaterialTheme.typography.titleMedium) + Text( + configurationSystemId ?: sourceSystemId, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = UdroidMuted, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + ) + } + if (!creating) { + UdroidStatusBadge( + label = if (active) "Active" else "Installed", + color = UdroidForest, + background = UdroidSoftGreen, + ) + } + } + } + } + + item(key = "configuration-name") { + OutlinedTextField( + value = draft.name, + enabled = editingEnabled, + onValueChange = { + draft = draft.copy(name = it) + message = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Configuration name") }, + supportingText = { + Text("This name identifies both the profile and its attached distro.") + }, + singleLine = true, + shape = RoundedCornerShape(10.dp), + ) + } + + if (!editingEnabled) { + item(key = "configuration-editor-locked") { + Surface(color = UdroidWarningSurface, shape = RoundedCornerShape(10.dp)) { + Text( + if (creating) { + "Finish the current Linux setup before creating another configuration." + } else { + "Stop the attached distro before changing this configuration." + }, + modifier = Modifier.padding(12.dp), + color = UdroidWarning, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + item(key = "configuration-warning") { + Surface(color = UdroidWarningSurface, shape = RoundedCornerShape(10.dp)) { + Text( + "Mappings are applied exactly as saved. Removing /sys, /proc, or another " + + "system path can intentionally prevent Linux from starting; uDroid keeps " + + "the configuration and reports the crash.", + modifier = Modifier.padding(12.dp), + color = UdroidWarning, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + item(key = "configuration-defaults-label") { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Mount mappings", style = MaterialTheme.typography.titleMedium) + Text( + "${PROOT_DEFAULT_MOUNTS.count { draft.isDefaultEnabled(it.id) }} of " + + "${PROOT_DEFAULT_MOUNTS.size} uDroid defaults enabled", + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton( + enabled = editingEnabled, + onClick = { + draft = + ProotMountProfile( + name = draft.name, + sourceSystemId = sourceSystemId, + ) + message = null + }, + ) { + Icon(Icons.Outlined.RestartAlt, contentDescription = null) + Spacer(Modifier.width(5.dp)) + Text("Restore") + } + } + } + + item(key = "configuration-defaults") { + Surface( + color = Color.Transparent, + border = BorderStroke(1.dp, UdroidLine), + shape = RoundedCornerShape(12.dp), + ) { + Column { + PROOT_DEFAULT_MOUNTS.forEachIndexed { index, mount -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + mount.guestTarget, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + mount.label, + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + Switch( + checked = draft.isDefaultEnabled(mount.id), + enabled = editingEnabled, + onCheckedChange = { enabled -> + draft = draft.withDefaultEnabled(mount.id, enabled) + message = null + }, + ) + } + if (index != PROOT_DEFAULT_MOUNTS.lastIndex) { + Divider(color = UdroidLine) + } + } + } + } + } + + item(key = "configuration-custom-label") { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Custom mappings", style = MaterialTheme.typography.titleMedium) + Text( + "Absolute host path to absolute guest destination", + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + OutlinedButton( + enabled = editingEnabled, + onClick = { + draft = + draft.copy( + customMounts = + draft.customMounts + + ProotCustomMount(hostSource = "", guestTarget = ""), + ) + message = null + }, + shape = RoundedCornerShape(9.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null) + Spacer(Modifier.width(5.dp)) + Text("Add") + } + } + } + + if (draft.customMounts.isEmpty()) { + item(key = "configuration-custom-empty") { + Surface(color = UdroidInset, shape = RoundedCornerShape(11.dp)) { + Text( + "No custom mappings. Add one when this configuration needs another path.", + modifier = Modifier.padding(14.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } else { + items(draft.customMounts, key = ProotCustomMount::id) { mount -> + CustomMountEditor( + mount = mount, + enabled = editingEnabled, + onChange = { changed -> + draft = draft.updateCustomMount(mount.id) { changed } + message = null + }, + onDelete = { + draft = + draft.copy( + customMounts = + draft.customMounts.filterNot { it.id == mount.id }, + ) + message = null + }, + ) + } + } + + (message ?: externalMessage)?.let { visibleMessage -> + item(key = "configuration-message") { + Text( + visibleMessage, + color = + if (visibleMessage.startsWith("Configuration saved")) { + UdroidForest + } else { + MaterialTheme.colorScheme.error + }, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + item(key = "configuration-submit") { + Button( + modifier = Modifier.fillMaxWidth(), + enabled = editingEnabled && !saving && (creating || dirty), + onClick = ::submit, + shape = RoundedCornerShape(10.dp), + ) { + Text( + when { + saving -> "Saving…" + creating -> "Create distro" + else -> "Save configuration" + }, + ) + } + } + + item { Spacer(Modifier.height(24.dp)) } + } + + if (confirmDiscard) { + AlertDialog( + onDismissRequest = { confirmDiscard = false }, + title = { + Text(if (creating) "Discard configuration?" else "Unsaved changes") + }, + text = { + Text( + if (creating) { + "This new mount configuration has not been created yet." + } else { + "Your changes to this configuration have not been saved." + }, + ) + }, + dismissButton = { + TextButton(onClick = { confirmDiscard = false }) { Text("Keep editing") } + }, + confirmButton = { + TextButton(onClick = onBack) { Text("Discard") } + }, + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun CustomMountEditor( + mount: ProotCustomMount, + enabled: Boolean, + onChange: (ProotCustomMount) -> Unit, + onDelete: () -> Unit, +) { + Surface(color = UdroidInset, shape = RoundedCornerShape(11.dp)) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Mapping", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelLarge, + ) + Switch( + checked = mount.enabled, + enabled = enabled, + onCheckedChange = { onChange(mount.copy(enabled = it)) }, + ) + IconButton(enabled = enabled, onClick = onDelete) { + Icon( + Icons.Outlined.DeleteOutline, + contentDescription = "Delete mapping", + tint = MaterialTheme.colorScheme.error, + ) + } + } + OutlinedTextField( + value = mount.hostSource, + enabled = enabled, + onValueChange = { onChange(mount.copy(hostSource = it)) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Host source") }, + placeholder = { Text("/data/local/project") }, + singleLine = true, + textStyle = + MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = mount.guestTarget, + enabled = enabled, + onValueChange = { onChange(mount.copy(guestTarget = it)) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Guest destination") }, + placeholder = { Text("/workspace") }, + singleLine = true, + textStyle = + MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + } + } +} + +private fun ProotMountProfile.updateCustomMount( + id: String, + update: (ProotCustomMount) -> ProotCustomMount, +): ProotMountProfile = + copy(customMounts = customMounts.map { if (it.id == id) update(it) else it }) diff --git a/app/src/main/java/org/randomcoder/udroid/ui/WorkspaceJourney.kt b/app/src/main/java/org/randomcoder/udroid/ui/WorkspaceJourney.kt index 1e7968c..415fdab 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/WorkspaceJourney.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/WorkspaceJourney.kt @@ -37,6 +37,8 @@ fun workspaceJourney( UdroidDestination.entries.filterNot { it == UdroidDestination.SYSTEM || it == UdroidDestination.INSTALL || + it == UdroidDestination.MOUNTS || + it == UdroidDestination.MOUNT_EDITOR || it == UdroidDestination.DESKTOP || (compactNavigation && it == UdroidDestination.DEVICE) } @@ -59,5 +61,7 @@ val UdroidDestination.requiresInstalledLinux: Boolean get() = this == UdroidDestination.TERMINAL || this == UdroidDestination.SYSTEM || + this == UdroidDestination.MOUNTS || + this == UdroidDestination.MOUNT_EDITOR || this == UdroidDestination.APPS || this == UdroidDestination.DESKTOP 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 1dd9315..a9808ea 100644 --- a/app/src/test/java/org/randomcoder/udroid/install/InstallerWorkRequestTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/install/InstallerWorkRequestTest.kt @@ -39,6 +39,36 @@ class InstallerWorkRequestTest { assertEquals(expected, actual) } + @Test + fun `archive variation keeps its independent installation identity`() { + val source = + DistroVariant( + suite = "jammy", + variant = "base", + internalName = "ubuntu-jammy", + friendlyName = "Ubuntu 22.04", + architecture = "aarch64", + downloadUrl = "https://example.test/ubuntu.tar.xz", + sha256 = "b".repeat(64), + ) + val expected = + InstallerWorkRequest.Archive( + distro = source, + operationId = "variation-1234", + installationName = "ubuntu-jammy-v2", + displayName = "Ubuntu 22.04 · Variation 2", + ) + + val actual = + InstallerWorkRequestCodec.decode( + InstallerWorkRequestCodec.encode(expected), + ) + + assertEquals(expected, actual) + assertEquals("ubuntu-jammy", (actual as InstallerWorkRequest.Archive).distro.internalName) + assertEquals("ubuntu-jammy-v2", actual.installationName) + } + @Test fun `oci work survives service intent serialization without fake archive metadata`() { val expected = diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt new file mode 100644 index 0000000..d60bb36 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt @@ -0,0 +1,146 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProotMountProfilesTest { + @Test + fun `default profile preserves the existing six Android mappings`() { + assertEquals( + listOf( + "/system", + "/apex", + "/dev", + "/proc", + "/sys", + "/linkerconfig/ld.config.txt", + ), + ProotMountResolver.resolve(ProotMountProfile()).map { it.argument }, + ) + } + + @Test + fun `required-looking default can be intentionally disabled`() { + val profile = ProotMountProfile().withDefaultEnabled("android.sys", enabled = false) + + ProotMountProfileValidator.requireValid(profile) + + assertFalse(ProotMountResolver.resolve(profile).any { it.guestTarget == "/sys" }) + } + + @Test + fun `custom mapping is emitted exactly as saved without checking host existence`() { + val profile = + ProotMountProfile( + customMounts = + listOf( + ProotCustomMount( + id = "experiment.data", + hostSource = "/data/local/nonexistent-source", + guestTarget = "/experiment", + ), + ), + ) + + assertEquals( + "/data/local/nonexistent-source:/experiment", + ProotMountResolver.resolve(profile).last().argument, + ) + } + + @Test + fun `duplicate enabled guest destinations are rejected`() { + val profile = + ProotMountProfile( + customMounts = + listOf( + ProotCustomMount( + id = "duplicate.sys", + hostSource = "/another/sys", + guestTarget = "/sys", + ), + ), + ) + + val failure = runCatching { ProotMountProfileValidator.requireValid(profile) }.exceptionOrNull() + + assertTrue(failure is IllegalArgumentException) + } + + @Test + fun `profile codec round trips overrides and custom mappings`() { + val expected = + ProotMountProfile( + name = "Build environment", + sourceSystemId = "udroid-jammy-raw", + defaultOverrides = mapOf("android.proc" to false), + customMounts = + listOf( + ProotCustomMount( + id = "custom.cache", + enabled = false, + hostSource = "/data/cache", + guestTarget = "/var/cache/host", + ), + ), + ) + + assertEquals(expected, ProotMountProfileCodec.decode(ProotMountProfileCodec.encode(expected))) + } + + @Test + fun `legacy profile without a name receives the default profile name`() { + val legacy = + """{"format":"1","defaults_revision":1,"default_overrides":{},"custom_mounts":[]}""" + + assertEquals("Default profile", ProotMountProfileCodec.decode(legacy).name) + } + + @Test + fun `blank profile name is rejected`() { + val failure = + runCatching { + ProotMountProfileValidator.requireValid(ProotMountProfile(name = " ")) + }.exceptionOrNull() + + assertTrue(failure is IllegalArgumentException) + } + + @Test + fun `copied profile gets independent custom mapping identities`() { + val original = + ProotMountProfile( + sourceSystemId = "udroid-jammy-raw", + customMounts = + listOf( + ProotCustomMount( + id = "custom.logs", + hostSource = "/data/logs", + guestTarget = "/mnt/logs", + ), + ), + ) + + val copied = original.independentCopy() + + assertNotEquals(original.customMounts.single().id, copied.customMounts.single().id) + assertEquals(original.sourceSystemId, copied.sourceSystemId) + assertEquals(original.customMounts.single().hostSource, copied.customMounts.single().hostSource) + assertEquals(original.customMounts.single().guestTarget, copied.customMounts.single().guestTarget) + } + + @Test + fun `unsafe source system identity is rejected`() { + val failure = + runCatching { + ProotMountProfileValidator.requireValid( + ProotMountProfile(sourceSystemId = "../another-system"), + ) + }.exceptionOrNull() + + assertTrue(failure is IllegalArgumentException) + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/ui/WorkspaceJourneyTest.kt b/app/src/test/java/org/randomcoder/udroid/ui/WorkspaceJourneyTest.kt index 1a0d88b..7dbe4b3 100644 --- a/app/src/test/java/org/randomcoder/udroid/ui/WorkspaceJourneyTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/ui/WorkspaceJourneyTest.kt @@ -146,6 +146,37 @@ class WorkspaceJourneyTest { NavigationMotion.BACK, navigationMotion(UdroidDestination.SYSTEM, UdroidDestination.DISTROS), ) + assertEquals( + NavigationMotion.FORWARD, + navigationMotion(UdroidDestination.SYSTEM, UdroidDestination.MOUNTS), + ) + assertEquals( + NavigationMotion.BACK, + navigationMotion(UdroidDestination.MOUNTS, UdroidDestination.SYSTEM), + ) + assertEquals( + NavigationMotion.FORWARD, + navigationMotion(UdroidDestination.MOUNTS, UdroidDestination.MOUNT_EDITOR), + ) + assertEquals( + NavigationMotion.BACK, + navigationMotion(UdroidDestination.MOUNT_EDITOR, UdroidDestination.MOUNTS), + ) + } + + @Test + fun `mount pages stay available without becoming navigation tabs`() { + val journey = + workspaceJourney( + requestedDestination = UdroidDestination.MOUNTS, + hasInstalledLinux = false, + hasInstallation = false, + compactNavigation = true, + ) + + assertEquals(UdroidDestination.DISTROS, journey.destination) + assertFalse(journey.destinations.contains(UdroidDestination.MOUNTS)) + assertFalse(journey.destinations.contains(UdroidDestination.MOUNT_EDITOR)) } @Test diff --git a/docs/CONFIGURABLE_PROOT_MOUNTS_PLAN.md b/docs/CONFIGURABLE_PROOT_MOUNTS_PLAN.md new file mode 100644 index 0000000..65970ce --- /dev/null +++ b/docs/CONFIGURABLE_PROOT_MOUNTS_PLAN.md @@ -0,0 +1,495 @@ +# Configurable PRoot mount mappings + +Status: implemented design and architecture, 2026-08-13 +Scope: distro-scoped mount profiles and independently installed variations. + +## Decision summary + +Mount mappings should belong to an installed Linux **system instance**, not to +the catalogue distro definition and not to the rootfs contents. A user may +therefore install Ubuntu 22.04 LTS more than once, give each installation a +different name, filesystem, and mount profile, and run each as a separate +uDroid system. + +The first implementation should: + +1. add a mount-mapping editor to the install review and installed-system page; +2. preserve the current Android binds as versioned uDroid defaults; +3. let defaults be enabled or disabled, and let custom mappings be added, + disabled, or deleted; +4. restore the exact current uDroid defaults with one explicit action; +5. store only per-system overrides and custom mappings outside the guest + rootfs; +6. resolve one immutable mount plan before any terminal, desktop, or graphical + application is started, validating only that it can be represented safely as + a PRoot argument vector; +7. keep installer/extraction mounts and session-owned X11/audio mounts outside + user control. + +The profile is intentionally authoritative. uDroid does not decide that +`/sys`, `/proc`, `/dev`, or another default is required for a developer's +experiment. If a user disables it, uDroid saves and launches that exact +profile. The distro may start, partially work, or exit immediately. In the +failure case, uDroid reports a graceful runtime crash and keeps the profile +unchanged for inspection, retry, or manual restoration. + +Creating another variation should reinstall the selected source into another +independent rootfs. It should not rename or byte-copy an existing rootfs. The +current extraction contract deliberately uses the final path from the first +extracted byte because PRoot hard-link translations can contain that stable +path. + +## What exists today + +The runtime has one shared hardcoded list in +`runtime/ProotBindMounts.kt`: + +```text +/system +/apex +/dev +/proc +/sys +/linkerconfig/ld.config.txt +``` + +That list is appended independently by the terminal, desktop, graphical-app, +and rootfs-health-check builders. X11 and PulseAudio authentication paths are +then appended dynamically by the relevant launch builder. + +Installed rootfs discovery is directory based: every child of +`filesDir/rootfs` with a `.udroid-ready` marker is an installed system. The +directory name is also used as identity by the active-system preference, +installer-source store, desktop settings, audio settings, runtime state, +terminal tabs, shortcuts, and UI routing. + +This creates four constraints for the feature: + +- two installations from the same catalogue item currently collide because an + archive work request derives its installation name from + `DistroVariant.internalName`; +- changing one of the several argument builders can produce inconsistent + runtime behavior; +- structured mount lists do not fit naturally into the existing primitive + `SharedPreferences` stores; +- an installed rootfs path must remain stable after installation. + +## Product model + +Use three separate concepts in the UI and data model: + +| Concept | Example | Mutability | +| --- | --- | --- | +| Distro source | Ubuntu 22.04 LTS, Jammy archive | Immutable catalogue/install source | +| Linux system instance | `Jammy · Web development` | Independent identity, rootfs, and settings | +| Mount profile | defaults plus `/workspace` mapping | Editable while the system is stopped | + +A distro's **configuration library** is the collection of system profiles that +share the same `sourceSystemId`. Each configuration card therefore represents +both a named mount profile and the independent distro created for that profile. + +The catalogue remains a source browser. Selecting an already installed source +must no longer imply there can be only one installation. Its actions should be +`Open installed systems` and `Create another system`. + +### Ownership invariant + +Every installed distro/rootfs instance owns exactly one mount profile, keyed by +its stable `systemId`. The profile is never global and is never keyed only by a +catalogue identity such as `ubuntu:jammy:raw`. + +This distinction is required because: + +- Ubuntu, Alpine, Debian, and other distros can have different filesystem + layouts and boot requirements; +- two installations from the same source can deliberately use different + mappings; +- resetting one rootfs should retain only that instance's profile; +- deleting one instance must not affect another instance created from the same + catalogue source. + +`Create variation` copies the selected profile into a new profile document with +new mapping IDs and a new `systemId`; later edits do not affect the original. +At launch the lookup is always: + +```text +systemId -> mounts.json -> resolved mount plan -> systemId's rootfs +``` + +### Entry points + +1. **Linux systems:** remains a distro browser. There is no global mounts tab + and no mount entry point on Home. +2. **Installed system:** its `Mount mappings` section contains the single + `Configure mounts` action. This opens the source distro's configuration + library, including when entered from a generated variation. +3. **Configuration library:** shows a `Create configuration` action and one + card per named configuration. A card can open its attached distro, edit its + mappings, or delete the configuration and generated filesystem. The source + configuration cannot be deleted from this screen. +4. **Create configuration:** opens a new mapping editor with a required name + and a copy of the source profile. `Create distro` prepares a fresh install + and saves the independent profile under the generated system ID. +5. **Edit configuration:** opens the same mapping editor for an existing + attached distro. Saving affects only that distro's next launch. +6. **Before install:** initial install review may configure the profile that + will be attached to that installation. Once installed, profile management + happens only through the distro detail page. + +### Editor behavior + +- A default row can be enabled or disabled, but not deleted. +- A custom row has an enabled switch, host source, absolute guest destination, + and delete action. +- `Add mapping` creates a draft row; it is not persisted until the whole editor + is structurally valid. +- `Restore uDroid defaults` removes every custom row and every default override + after confirmation. It is deliberately distinct from `Reset filesystem`. +- A resolved-command preview may be offered under an advanced disclosure, but + the app must pass an argument array directly to PRoot and never construct a + shell command string. +- Read-only must not be shown in v1. PRoot binds inherit the accessibility of + the host source; a UI switch would promise enforcement the current runtime + does not provide. +- Disabling a default may make the distro fail during startup. Show that as an + informational warning, not as a validation error or a reason to re-enable the + mount automatically. + +## Proposed runtime architecture + +```mermaid +flowchart TD + UI["Install review or system settings"] --> Draft["Mount profile draft"] + Draft --> Validator["Schema and argument-safety validator"] + Validator --> Store["Atomic app-private profile store"] + Store --> Resolver["Per-system mount resolver"] + Defaults["Versioned uDroid runtime defaults"] --> Resolver + Session["Supervisor-owned X11 and audio mounts"] --> Resolver + Resolver --> Plan["Immutable resolved mount plan"] + Plan --> Assembler["One PRoot argument assembler"] + Assembler --> Terminal["Terminal launch"] + Assembler --> Desktop["Desktop launch"] + Assembler --> App["Graphical app launch"] + Terminal --> Outcome["Running or graceful crash"] + Desktop --> Outcome + App --> Outcome + Bootstrap["Fixed installer and health-check mounts"] --> Installer["Extraction and base-image health"] +``` + +The central rule is that terminal, desktop, and application launchers consume +the same resolved plan. They must not each merge or validate mappings. + +Suggested responsibilities: + +| Component | Responsibility | +| --- | --- | +| `ProotMountDefaults` | Stable IDs and the current six built-in runtime binds | +| `MountProfileStore` | Versioned JSON, atomic save, load, migration, remove | +| `ProotMountValidator` | Validate schema and safe `SRC[:DST]` representation, without judging boot viability | +| `ProotMountResolver` | Merge defaults, saved overrides, custom rows, and session mounts | +| `ResolvedProotMountPlan` | Immutable, already validated list used for one launch generation | +| `ProotArgumentAssembler` | Emit repeatable `-b`, `SRC[:DST]` argument pairs | + +The extraction pipeline and base-image health check keep a small fixed +bootstrap contract. User mappings do not affect archive extraction or the +one-time base-image health check. A successful installation therefore means +the base distro is valid; a later crash with a custom profile is a runtime +configuration outcome, not a corrupt installation. + +## Identity and storage + +Introduce a stable `systemId` without moving existing rootfs directories. +Existing installations receive an ID on first migration; their current +directory name becomes an immutable `storageDirectoryName`. + +```mermaid +flowchart LR + Source["Distro source"] --> A["System A · systemId A"] + Source --> B["System B · systemId B"] + A --> RootA["rootfs / immutable storage key A"] + A --> ProfileA["mount profile A"] + A --> SettingsA["desktop, audio, shortcuts"] + B --> RootB["rootfs / immutable storage key B"] + B --> ProfileB["mount profile B"] + B --> SettingsB["desktop, audio, shortcuts"] +``` + +Recommended app-private layout: + +```text +filesDir/ + rootfs/ + / + .udroid-ready + ...guest filesystem... + linux-systems/ + / + instance.json + mounts.json +``` + +`instance.json` owns display identity and source linkage. `mounts.json` is +outside the guest filesystem so a fake-root guest cannot edit its next-boot +host exposure, and so a filesystem reset can reinstall the rootfs without +silently losing the chosen profile. + +For the first schema, persist differences from the built-in defaults rather +than copying the entire default list: + +```json +{ + "schemaVersion": 1, + "defaultsRevision": 1, + "name": "Web development", + "sourceSystemId": "udroid-jammy-raw", + "defaultOverrides": { + "android.sys": { "enabled": false } + }, + "customMounts": [ + { + "id": "b07f6538-89a7-4a56-a213-c5a8a6ec508b", + "enabled": true, + "hostSource": "/storage/emulated/0/Projects/acme", + "guestTarget": "/workspace" + } + ] +} +``` + +Persisting only overrides gives `Restore uDroid defaults` a precise meaning: +clear `defaultOverrides` and `customMounts`. It also lets a later app release +add a new default without rewriting every profile. `defaultsRevision` +supports explicit migrations when a default changes meaning. + +The v1 profile stores the host source and guest destination as explicit paths. +This matches the developer-focused contract: uDroid passes the saved mappings +to PRoot and does not need a document picker, Drive integration, or a live +filesystem bridge. + +### Atomicity and recovery + +Write `mounts.json.tmp`, flush it, and atomically replace `mounts.json`. A +truncated or unsupported profile must block that system's launch with an +actionable editor error; it must not silently start with broader defaults. + +During a new installation: + +1. allocate `systemId` and immutable storage key; +2. validate the profile structure and save it as pending; +3. install and health-check the rootfs at its final path; +4. publish `.udroid-ready` and mark the instance ready; +5. on failure, retain the draft for retry or remove it when the install is + abandoned. + +If a legacy ready rootfs has no metadata or profile, discovery creates metadata +and an empty override profile, which resolves to the current hardcoded +behavior. + +## Resolution and precedence + +Resolve one plan in this order: + +1. enabled built-in defaults after applying overrides; +2. enabled custom mappings; +3. supervisor-owned mounts required for the requested session, such as X11 and + PulseAudio authentication. + +Reject an exact duplicate guest destination because its winner would be +unclear. Nested mappings are allowed and retain deterministic list order; they +are useful for deliberate overlays. To replace a default destination, the user +disables that default and adds the desired custom mapping. + +Reserved guest targets initially include: + +```text +/tmp/.X11-unix +/tmp/.udroid-pulse +``` + +X11 and audio targets remain reserved only because those bindings are injected +by the supervisor for a specific running session. They are not part of the +base profile. The editor explains the conflict instead of silently changing +the custom mapping. + +## Validation and failure policy + +Save-time validation answers only: “Can this profile be stored and converted +unambiguously into PRoot arguments?” It does not answer: “Will this distro +boot?” + +### Syntax + +- source and guest target are absolute normalized paths with no `.` or `..` + segment; +- neither side contains NUL, newline, or the PRoot source/destination delimiter; +- source and target lengths are bounded; +- IDs are unique and the number of custom mappings is bounded; +- exact duplicate guest destinations are rejected; +- no shell escaping is performed because each value is passed as a distinct + process argument. + +### Launch behavior + +- resolve the saved defaults and custom mappings without adding back disabled + defaults; +- log the resolved mapping list in the supervisor journal; +- invoke PRoot even when a conventional mount such as `/sys` is absent; +- never omit an enabled custom mapping merely because its source appears + unavailable during an Android-side precheck; +- capture PRoot's exit code and bounded stderr when startup fails; +- transition the terminal/runtime state to `CRASHED` with `Edit mounts`, + `Retry`, and `Restore defaults` recovery actions; +- retain the exact saved profile after a crash. + +### Exposure levels + +The editor should explain that PRoot is path translation, not a VM or a kernel +container. A guest process has the same Android app UID and can use the same +host permissions as uDroid. A writable mapping can therefore modify its host +source. + +The editor needs one concise disclosure: a saved profile is applied as written, +and disabling system paths may prevent the distro from starting. That warning +must not be presented as a permission gate. + +## Lifecycle + +```mermaid +sequenceDiagram + participant User + participant UI as Mount editor + participant Store as Profile store + participant Supervisor + participant Resolver + participant PRoot + + User->>UI: Save profile + UI->>Resolver: Validate structure and argument safety + Resolver-->>UI: Resolved preview with boot-risk warnings + UI->>Store: Atomic save + User->>Supervisor: Start system + Supervisor->>Store: Load profile for systemId + Supervisor->>Resolver: Resolve defaults + custom + session + Resolver-->>Supervisor: Exact immutable plan + Supervisor->>PRoot: argv with repeated -b pairs + alt Profile works + PRoot-->>Supervisor: Running generation + else Profile does not work + PRoot-->>Supervisor: Exit code and stderr + Supervisor-->>User: Graceful crash; profile unchanged + end +``` + +The supervisor snapshots the plan for a launch generation. Editing is blocked +while that system owns a terminal or desktop, so every child in the generation +sees the same mount namespace contract. Stop/start is the apply boundary. + +Resetting the rootfs retains `instance.json` and `mounts.json`. Deleting a +system removes its rootfs, profile, source link, desktop/audio settings, +shortcuts, and terminal-tab state. `Restore mount defaults` changes only +`mounts.json`. + +## Delivery plan + +### Phase 1 — model and common resolver + +- Add mount models, default IDs, JSON codec/store, validator, and migrations. +- Add stable system metadata while preserving existing rootfs paths. +- Add a single argument-assembly path used by terminal, desktop, and + graphical-app launches. +- Keep extraction and base health checks on a separate fixed bootstrap list. +- Migrate every legacy system to an empty override profile. + +Exit gate: current launch vectors are byte-for-byte equivalent for migrated +systems. + +### Phase 2 — installed-system editor + +- Add the mount section and editor to `LinuxSystemPage`. +- Support enable/disable, add/delete, validation, and restore defaults. +- Show resolved source/target and next-start behavior. +- Block save while the selected system is running. + +Exit gate: terminal, desktop, and direct app launch receive the same exact +profile; disabling `/sys` or another default is accepted, and any resulting +PRoot exit becomes a graceful `CRASHED` state with diagnostics. + +### Phase 3 — independent variations + +- Separate archive installation name from `DistroVariant.internalName`. +- Add system display name, stable ID, and generated immutable storage key to + installer work requests and recovery markers. +- Add `Create another system` and `Create variation` review flows. +- Reinstall from the recorded source into a new rootfs and copy the profile as + a new independent document. + +Exit gate: two Ubuntu 22.04 systems can coexist, differ only in mount profile, +and be reset/deleted independently. + +### Phase 4 — developer portability + +- Import/export a versioned mount-profile JSON document. +- Redact or explicitly flag device-specific raw paths. +- Add shareable templates that contain no system ID or private absolute path. + +## Test matrix + +### Unit tests + +- codec round trip, version rejection, and migration; +- default override merge and restore-default behavior; +- duplicate-target, reserved-target, traversal, delimiter, and length + rejection; +- nested mapping order and disabled-default replacement; +- deterministic argument order and no shell interpolation; +- all three runtime builders receive the identical resolved binds; +- legacy system with no profile resolves to the six current defaults. + +### Instrumented tests + +- custom directory is visible at the requested guest path; +- disabled default is absent; +- file-to-file and directory-to-directory mappings work; +- host writes made by the guest behave as disclosed; +- disabling `/sys`, `/proc`, or `/dev` still creates and launches the exact + saved profile; +- a failing profile records exit code/stderr, enters `CRASHED`, and remains + unchanged; +- retry uses the same failed profile until the user edits or restores it; +- X11 and audio reserved mappings cannot be overridden; +- process/service recreation resolves the same profile; +- reset keeps the profile; delete removes it. + +### Device scenarios + +- Android 8 minimum SDK and Android 16 target behavior; +- restart after app process death; +- two installations from the same distro source; +- terminal tabs, desktop, and direct application launch; +- upgrade from a legacy install containing absolute PRoot hard-link + translations. + +## Decisions to keep explicit + +1. **No silent fallback or repair.** uDroid launches the exact saved profile; + it never restores a disabled default behind the user's back. +2. **No live mutation.** A profile is immutable for a running supervisor + generation. +3. **No fake read-only mode.** Add it only after there is enforceable runtime + support. +4. **Graceful configuration failure.** An immediate PRoot exit becomes a + diagnosable `CRASHED` state while the profile remains unchanged. +5. **No rootfs rename/clone shortcut.** A variation uses a fresh independent + install at its final immutable path. +6. **No user control of internal session bridges.** X11, audio, extraction, and + installer-health mounts retain uDroid ownership. + +## Primary references + +- [PRoot overview and bind examples](https://proot-me.github.io/): PRoot uses + `-b SRC:DST` to relocate host files/directories into the guest and performs + user-space path translation rather than kernel container isolation. +- [termux/proot-distro login and bind contract](https://github.com/termux/proot-distro#usage): + custom binds are repeatable, destinations are absolute, Android default and + minimal/isolated mount sets are distinct, and overlapping destinations are + warned about rather than rejected. diff --git a/docs/images/mount-configurations/01-distro-entry.png b/docs/images/mount-configurations/01-distro-entry.png new file mode 100644 index 0000000..91c8aaa Binary files /dev/null and b/docs/images/mount-configurations/01-distro-entry.png differ diff --git a/docs/images/mount-configurations/02-configuration-library.png b/docs/images/mount-configurations/02-configuration-library.png new file mode 100644 index 0000000..6e678e3 Binary files /dev/null and b/docs/images/mount-configurations/02-configuration-library.png differ diff --git a/docs/images/mount-configurations/03-create-configuration.png b/docs/images/mount-configurations/03-create-configuration.png new file mode 100644 index 0000000..a7cee4b Binary files /dev/null and b/docs/images/mount-configurations/03-create-configuration.png differ diff --git a/docs/images/mount-configurations/04-mapping-editor.png b/docs/images/mount-configurations/04-mapping-editor.png new file mode 100644 index 0000000..429194f Binary files /dev/null and b/docs/images/mount-configurations/04-mapping-editor.png differ diff --git a/docs/images/mount-configurations/05-open-configured-distro.png b/docs/images/mount-configurations/05-open-configured-distro.png new file mode 100644 index 0000000..9c13d9e Binary files /dev/null and b/docs/images/mount-configurations/05-open-configured-distro.png differ diff --git a/docs/images/mount-configurations/06-configured-mount-summary.png b/docs/images/mount-configurations/06-configured-mount-summary.png new file mode 100644 index 0000000..fd8e3ec Binary files /dev/null and b/docs/images/mount-configurations/06-configured-mount-summary.png differ