diff --git a/README.md b/README.md index d55e0bc..9e1da0f 100644 --- a/README.md +++ b/README.md @@ -12,133 +12,45 @@ supervised way to install and use Linux distributions on Android. ## Download and install -[Download uDroid from GitHub Releases](https://github.com/RandomCoderOrg/udroid-app/releases), -open the newest prerelease, and select its `.apk` file. Android may ask you to -allow installs from the browser or file manager you used. - -Existing stable-signed builds can also download and verify newer releases from -the app's **About** page. - -## Current feature support - -| Area | What is supported | -| --- | --- | -| Linux installation | Search uDroid and PRoot-Distro archives alongside official Docker Hub operating-system images. Choose a compatible OCI tag, review its architecture and compressed size, resume transfers, verify SHA-256 digests, and retain validated data across retries. | -| Multiple systems | Keep multiple distributions installed. Selecting an installed system opens its own status and controls page instead of immediately starting a terminal. | -| Terminal | Use the bundled Termux terminal emulator and persistent, service-owned PTY without installing Termux separately. Each terminal action targets the selected rootfs. | -| Linux applications | Discover freedesktop `.desktop` entries, launch graphical or terminal applications, publish dynamic shortcuts, and pin selected applications to the Android home screen. | -| Embedded X11 | Use the bundled Termux:X11 server and uDroid display surface without installing the separate Termux:X11 application. Keyboard, direct-pointer, trackpad-style, native multi-touch, and display settings are available inside uDroid. | -| Desktop environments | Detect installed X11 sessions from standard `xsessions` directories. Start, stop, or restart the selected desktop while uDroid reports which rootfs and desktop own `DISPLAY :0`. | -| Compositing | Enable or disable compositing for supported XFCE, Plasma, and MATE sessions, then apply the setting through a supervised desktop restart. GNOME is treated as compositor-required. | -| Audio | Play Linux audio through Android with an app-owned PulseAudio/OpenSL ES bridge. Enable microphone input per Linux system with Android runtime consent; guest traffic stays on authenticated device-local loopback. | - -### Current limitations - -- uDroid currently supervises one active Linux runtime and one X11 display, - `DISPLAY :0`, at a time. Multiple distributions can remain installed, but - their live desktop sessions are not concurrent. -- Desktop lifecycle support currently targets X11 sessions advertised through - `/usr/share/xsessions` or `/usr/local/share/xsessions`. Wayland sessions are - not launched. -- Compositor control is desktop-specific. LXQt and unknown/custom sessions are - still launchable, but uDroid does not guess at a compositor switch when no - safe standard adapter is known. -- PRoot provides the Linux userspace. It is not a virtual machine, does not - provide a normal systemd/logind boot, and cannot reproduce every native Linux - service or sandbox boundary. -- The embedded display is functional, but general-purpose GPU acceleration is - not yet a supported app contract. Desktop responsiveness depends on the - device, resolution, applications, and compositor choice. -- Background desktop recovery, multiple displays, and production-level desktop - compatibility remain incomplete. Audio currently targets PulseAudio clients; - direct ALSA-only applications may require a PulseAudio compatibility plugin. - -See the [distribution catalogue](docs/DISTRIBUTION_CATALOGUE.md), -[OCI image architecture](docs/OCI_IMAGES.md), -[Linux application launcher](docs/LINUX_APPLICATION_LAUNCHER.md), -[X11 runtime architecture](docs/X11_RUNTIME_ARCHITECTURE.md), -[audio runtime](docs/AUDIO_RUNTIME.md), -[performance notes](docs/PERFORMANCE.md), and -[app updates](docs/APP_UPDATES.md) for current behavior and technical details. - -## Development releases - -Git tags matching `v*` are built by GitHub Actions. Each resulting prerelease -contains: - -- one universal, optimized APK covering `arm64-v8a`, `armeabi-v7a`, and - `x86_64`, signed with the persistent project update key; -- `SHA256SUMS` for verifying the downloaded APK; -- GitHub's source archives, including the vendored Termux terminal components - and the corresponding third-party notices. - -Pull requests and non-tag workflow runs publish a separate optimized -`uDroid Dev` APK for 30 days. It uses the `org.randomcoder.udroid.dev` -application ID and the Android debug key, so it can be installed beside the -update-signed app without replacing it. PR artifacts are named with the pull -request number and head commit, for example `udroid-pr-24-cf9c1ed-dev.apk`. -They are test artifacts and cannot update either the official app or another -machine's debug build. - -The published `v0.0.2` APK predates stable update signing and retains its -original debug asset name. It is a development build, not a Play Store or -production-signed release: - -```sh -adb install -r udroid-v0.0.2-debug.apk -``` - -Tagged releases after this updater checkpoint require these GitHub Actions -secrets: - -- `UDROID_SIGNING_STORE_BASE64` -- `UDROID_SIGNING_STORE_PASSWORD` -- `UDROID_SIGNING_KEY_ALIAS` -- `UDROID_SIGNING_KEY_PASSWORD` - -The first stable-signed development release cannot replace the older -ephemeral-debug-signed APK in place. Existing testers must uninstall and -install that release once; subsequent releases signed by the same key can use -the in-app updater. - -## Build - -Requirements: - -- JDK 17 or newer -- Android SDK platform 36 -- Android NDK 28.2 (the probes also build with NDK 26+) - -```sh -export ANDROID_HOME="$HOME/Library/Android/sdk" -export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/28.2.13676358" -./app/src/main/cpp/build-runtime-probe.sh -./tools/build-proot-assets.sh -./tools/build-gnu-tar-assets.sh -./tools/build-pulseaudio-assets.sh -./gradlew :app:assembleRelease -``` - -The app targets API 36. On Android 10 and newer it launches packaged Android -ELFs through `/system/bin/linker(64)`. PRoot's static guest loader is installed -as an extracted APK native library so its second execution hop is not blocked -by Android's writable-app-data execution policy. - -UI performance is measured from optimized builds with device Macrobenchmarks, -Perfetto traces, and generated Baseline Profiles. See -[Performance](docs/PERFORMANCE.md) for the commands and current catalogue -results. - -## Licensing - -The uDroid-owned Android shell is MIT-licensed, matching -`fs-manager-udroid`. Packaged PRoot is GPL-2.0 and statically links talloc, -whose library is LGPL-3.0-or-later. The rootfs installer also packages GNU tar -and Termux's BSD-licensed libandroid-glob implementation. The vendored Termux -terminal emulator and view are the Apache-2.0 components identified by -Termux's upstream license exception. The embedded Termux:X11 module is GPLv3, -so APKs containing it are distributed as GPLv3 combined works. Exact source -versions, checksums, local changes, and build commands are recorded in -`tools/` and `third_party/`; binary releases must also provide the applicable -corresponding source and license texts. See -[Third-party notices](THIRD_PARTY_NOTICES.md). +1. Download the newest `.apk` from + [GitHub Releases](https://github.com/RandomCoderOrg/udroid-app/releases). +2. Open it and approve the installation. + +Android may ask you to allow installs from your browser or file manager. Once +installed, uDroid can check for updates from **About**. + +## Feature support + +- Install Linux images from uDroid and PRoot-Distro. +- Keep and manage multiple Linux systems. +- Use a built-in, persistent terminal. +- Launch X11 desktops and installed Linux apps. +- Use touch, mouse, keyboard, and multi-touch input. +- Play audio and optionally use the microphone. +- Configure mounts and environment variables per system. +- Check for updates and copy diagnostic logs. + +## Limitations + +- One Linux system and one X11 display can run at a time. +- Wayland sessions are not supported yet. +- PRoot is not a virtual machine and cannot grant real root privileges. Docker, + LXC, Flatpak, Snap, and tools that need privileged mounts, namespaces, or + device access may not work. +- Graphics acceleration is experimental and device-dependent. Venus, VirGL, + and ANGLE may work on supported Mali devices. Turnip is not supported yet + because I do not have a Snapdragon device to port and test it. +- Desktop integration varies between environments. + +## Documentation + +User guide: [Using uDroid](docs/USER_GUIDE.md). + +Developer reference: [builds and releases](docs/DEVELOPMENT.md), +[product scope](docs/PRODUCT_SCOPE.md), [Linux systems](docs/DISTRIBUTION_CATALOGUE.md), +[Linux apps](docs/LINUX_APPLICATION_LAUNCHER.md), +[desktop](docs/X11_RUNTIME_ARCHITECTURE.md), [audio](docs/AUDIO_RUNTIME.md), +[updates](docs/APP_UPDATES.md), and [performance](docs/PERFORMANCE.md). + +Licensing: [MIT license](LICENSE) and +[third-party notices](THIRD_PARTY_NOTICES.md). diff --git a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt index f448cef..975f609 100644 --- a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt +++ b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.randomcoder.udroid.audio.AudioConfiguration import org.randomcoder.udroid.audio.AudioConfigurationStore +import org.randomcoder.udroid.catalog.DistroArchiveSizeStore import org.randomcoder.udroid.catalog.DistroCatalogRepository import org.randomcoder.udroid.catalog.DistroCatalogState import org.randomcoder.udroid.catalog.DistroVariant @@ -43,13 +44,11 @@ import org.randomcoder.udroid.linuxapps.LinuxApplication import org.randomcoder.udroid.linuxapps.LinuxApplicationShortcutContract import org.randomcoder.udroid.linuxapps.LinuxApplicationShortcutPublisher import org.randomcoder.udroid.linuxapps.LinuxApplicationsState -import org.randomcoder.udroid.oci.OciHubCatalogRepository import org.randomcoder.udroid.oci.OciHubCatalogueState import org.randomcoder.udroid.oci.OciHubRepository import org.randomcoder.udroid.oci.OciHubTagPlatform import org.randomcoder.udroid.oci.OciHubTagRepository import org.randomcoder.udroid.oci.OciHubTagsState -import org.randomcoder.udroid.oci.OciPlatform import org.randomcoder.udroid.runtime.CapabilityProbe import org.randomcoder.udroid.runtime.CapabilityResult import org.randomcoder.udroid.runtime.DesktopCompositorSupport @@ -67,6 +66,7 @@ import org.randomcoder.udroid.runtime.ProotMountResolver import org.randomcoder.udroid.runtime.RuntimePhase import org.randomcoder.udroid.runtime.RuntimeSnapshot import org.randomcoder.udroid.runtime.RuntimeSupervisorService +import org.randomcoder.udroid.runtime.RootfsStorageUsage import org.randomcoder.udroid.runtime.VirglServerMode import org.randomcoder.udroid.ui.UdroidApp import org.randomcoder.udroid.ui.UdroidCanvas @@ -116,6 +116,9 @@ class MainActivity : ComponentActivity() { private var selectedSystemRootfsName by mutableStateOf(null) private var rootfsMaintenanceName by mutableStateOf(null) private var rootfsMaintenanceMessage by mutableStateOf(null) + private var rootfsOccupiedBytes by mutableStateOf(null) + private var rootfsStorageLoading by mutableStateOf(false) + private var rootfsStorageName: String? = null private var desktopEnvironments by mutableStateOf>(emptyList()) private var desktopConfiguration by mutableStateOf(DesktopConfiguration(null, compositingEnabled = false, touchScaleEnabled = true)) @@ -134,16 +137,14 @@ class MainActivity : ComponentActivity() { private var pendingMicrophoneRootfsName: String? = null private var linuxApplicationsLoadGeneration = 0L private var desktopScanGeneration = 0L - private var ociCatalogueLoadGeneration = 0L private var ociTagsLoadGeneration = 0L - private var restoredOciRepositoryName: String? = null private var showInstallTerminal by mutableStateOf(false) private var runtimeService by mutableStateOf(null) private var runtimeServiceBound = false private val desktopConfigurationStore by lazy { DesktopConfigurationStore(this) } private val audioConfigurationStore by lazy { AudioConfigurationStore(this) } - private val ociHubCatalogueRepository by lazy { OciHubCatalogRepository(this) } private val ociHubTagRepository by lazy { OciHubTagRepository(this) } + private val distroArchiveSizeStore by lazy { DistroArchiveSizeStore(this) } private val microphonePermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> @@ -205,7 +206,6 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - restoredOciRepositoryName = savedInstanceState?.getString(STATE_OCI_REPOSITORY) applySystemBars(UdroidDestination.HOME) setContent { UdroidTheme { @@ -226,6 +226,8 @@ class MainActivity : ComponentActivity() { selectedSystemRootfsName = selectedSystemRootfsName, rootfsMaintenanceName = rootfsMaintenanceName, rootfsMaintenanceMessage = rootfsMaintenanceMessage, + rootfsOccupiedBytes = rootfsOccupiedBytes, + rootfsStorageLoading = rootfsStorageLoading, desktopEnvironments = desktopEnvironments, desktopConfiguration = desktopConfiguration, desktopScanLoading = desktopScanLoading, @@ -245,7 +247,7 @@ class MainActivity : ComponentActivity() { }, onStop = { RuntimeSupervisorService.stop(this) }, onRefresh = { refreshAll() }, - onReloadCatalogue = { loadCatalogue(forceOciRefresh = true) }, + onReloadCatalogue = { loadCatalogue() }, onPreviewInstall = { selectDistro(it) }, onSelectOciRepository = { selectOciRepository(it) }, onRetryOciTags = { @@ -256,6 +258,7 @@ class MainActivity : ComponentActivity() { onBackFromOciRepository = { closeOciRepository() }, onSelectOciTag = { repository, tag -> selectOciTag(repository, tag) }, onOpenInstalledSystem = { openSystemDetails(it) }, + onOpenInstallation = { openInstallationDetails(it) }, onOpenRootfsTerminal = { openRootfsTerminal(it) }, onOpenRootfsApps = { openRootfsApps(it) }, onResetRootfs = { rootfsName, fallback -> @@ -314,20 +317,15 @@ class MainActivity : ComponentActivity() { refreshAll() loadCatalogue() handleUpdateIntent(intent) + handleInstallerIntent(intent) if (!handleShortcutIntent(intent)) loadLinuxApplications() } - override fun onSaveInstanceState(outState: Bundle) { - selectedOciRepository?.name?.let { - outState.putString(STATE_OCI_REPOSITORY, it) - } - super.onSaveInstanceState(outState) - } - override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) handleUpdateIntent(intent) + handleInstallerIntent(intent) handleShortcutIntent(intent) } @@ -433,9 +431,15 @@ class MainActivity : ComponentActivity() { }.getOrNull() != null } }.toSet() - if (selectedSystemRootfsName !in installedRootfses.map(InstalledRootfs::name)) { + if ( + selectedSystemRootfsName !in installedRootfses.map(InstalledRootfs::name) && + selectedSystemRootfsName != installProgress?.installationName + ) { selectedSystemRootfsName = installedRootfsName } + selectedSystemRootfsName + ?.let { name -> installedRootfses.firstOrNull { it.name == name } } + ?.let(::loadRootfsStorageUsageIfNeeded) installProgress ?.takeIf { progress -> progress.stage == InstallStage.READY && @@ -471,7 +475,7 @@ class MainActivity : ComponentActivity() { launchPendingDesktop() } - private fun loadCatalogue(forceOciRefresh: Boolean = false) { + private fun loadCatalogue() { catalogueState = DistroCatalogState.Loading lifecycleScope.launch { catalogueState = @@ -488,50 +492,6 @@ class MainActivity : ComponentActivity() { }, ) } - loadOciCatalogue(forceRefresh = forceOciRefresh) - } - - private fun loadOciCatalogue(forceRefresh: Boolean) { - val generation = ++ociCatalogueLoadGeneration - ociCatalogueState = OciHubCatalogueState.Loading - lifecycleScope.launch { - val platform = - runCatching { - OciPlatform.fromAndroidAbis(Build.SUPPORTED_ABIS.toList()) - }.getOrElse { - if (generation == ociCatalogueLoadGeneration) { - ociCatalogueState = - OciHubCatalogueState.Failed( - it.message ?: "This phone architecture is not supported", - ) - } - return@launch - } - val result = - runCatching { - withContext(Dispatchers.IO) { - ociHubCatalogueRepository.load(forceRefresh = forceRefresh) - } - } - if (generation != ociCatalogueLoadGeneration) return@launch - ociCatalogueState = - result.fold( - onSuccess = { OciHubCatalogueState.Ready(it, platform) }, - onFailure = { - OciHubCatalogueState.Failed( - it.message ?: "Official image catalogue could not be read", - ) - }, - ) - val restoreName = restoredOciRepositoryName - if (restoreName != null && result.isSuccess) { - restoredOciRepositoryName = null - result.getOrThrow() - .repositories - .firstOrNull { it.name == restoreName } - ?.let(::selectOciRepository) - } - } } private fun selectDistro(distro: DistroVariant) { @@ -542,7 +502,9 @@ class MainActivity : ComponentActivity() { } showInstallTerminal = false installProgress = app.installState.save(InstallationSelection.initial(distro)) - selectDestination(UdroidDestination.INSTALL) + selectedSystemRootfsName = distro.internalName + selectDestination(UdroidDestination.SYSTEM) + resolveArchiveSize(installProgress!!, distro) } private fun selectOciRepository( @@ -617,7 +579,8 @@ class MainActivity : ComponentActivity() { displayArchitecture = displayArchitecture, ), ) - selectDestination(UdroidDestination.INSTALL) + selectedSystemRootfsName = installationName + selectDestination(UdroidDestination.SYSTEM) } private fun startSelectedDownload() { @@ -655,9 +618,52 @@ class MainActivity : ComponentActivity() { selectedSystemRootfsName = rootfsName rootfsMaintenanceMessage = null loadAudioConfiguration(rootfsName) + installedRootfses.firstOrNull { it.name == rootfsName }?.let(::loadRootfsStorageUsageIfNeeded) selectDestination(UdroidDestination.SYSTEM) } + private fun openInstallationDetails(installationName: String) { + if (installProgress?.installationName != installationName) return + selectedSystemRootfsName = installationName + selectDestination(UdroidDestination.SYSTEM) + } + + private fun resolveArchiveSize( + preview: InstallProgress, + distro: DistroVariant, + ) { + lifecycleScope.launch { + val bytes = + runCatching { + withContext(Dispatchers.IO) { distroArchiveSizeStore.resolve(distro) } + }.getOrDefault(0L) + val current = app.installState.current() + if ( + current?.operationId == preview.operationId && + current.stage == InstallStage.READY + ) { + installProgress = app.installState.save(current.copy(totalBytes = bytes)) + } + } + } + + private fun loadRootfsStorageUsageIfNeeded(rootfs: InstalledRootfs) { + if (rootfsStorageName == rootfs.name) return + rootfsStorageName = rootfs.name + rootfsOccupiedBytes = null + rootfsStorageLoading = true + lifecycleScope.launch { + val bytes = + runCatching { + withContext(Dispatchers.IO) { RootfsStorageUsage.calculate(rootfs.directory) } + }.getOrNull() + if (rootfsStorageName == rootfs.name) { + rootfsOccupiedBytes = bytes + rootfsStorageLoading = false + } + } + } + private fun deleteRootfs(rootfsName: String) { maintainRootfs(rootfsName, resetWork = null) } @@ -764,7 +770,8 @@ class MainActivity : ComponentActivity() { installProgress = progress showInstallTerminal = false rootfsMaintenanceMessage = null - selectDestination(UdroidDestination.INSTALL) + selectedSystemRootfsName = progress.installationName + selectDestination(UdroidDestination.SYSTEM) }, onFailure = { runCatching { @@ -898,9 +905,12 @@ class MainActivity : ComponentActivity() { rootfsMaintenanceName = null rootfsMaintenanceMessage = null selectedSystemRootfsName = null + rootfsStorageName = null + rootfsOccupiedBytes = null refreshFromDisk() if (resetWork != null) { - selectDestination(UdroidDestination.INSTALL) + selectedSystemRootfsName = resetWork.installationName + selectDestination(UdroidDestination.SYSTEM) ensureNotificationPermission() InstallerService.start(this@MainActivity, resetWork.work) } else { @@ -1387,6 +1397,16 @@ class MainActivity : ComponentActivity() { refreshFromDisk() } + private fun handleInstallerIntent(intent: Intent?) { + if (intent?.action != InstallerService.ACTION_SHOW_INSTALLATION) return + selectedSystemRootfsName = + intent.getStringExtra(InstallerService.EXTRA_INSTALLATION_NAME) + ?: app.installState.current()?.installationName + intent.action = null + refreshFromDisk() + selectDestination(UdroidDestination.SYSTEM) + } + private fun installDownloadedUpdate() { when (val result = AppUpdateInstaller.install(this, updateState)) { UpdateInstallResult.Submitted -> { @@ -1414,7 +1434,6 @@ 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/catalog/DistroArchiveSizeStore.kt b/app/src/main/java/org/randomcoder/udroid/catalog/DistroArchiveSizeStore.kt new file mode 100644 index 0000000..b03d647 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/catalog/DistroArchiveSizeStore.kt @@ -0,0 +1,76 @@ +package org.randomcoder.udroid.catalog + +import android.content.Context +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +class DistroArchiveSizeStore(context: Context) { + private val preferences = + context.getSharedPreferences("distro-archive-sizes", Context.MODE_PRIVATE) + + fun cached(distro: DistroVariant): Long? = + preferences.getLong(distro.cacheKey(), -1L).takeIf { it > 0L } + + fun resolve(distro: DistroVariant): Long { + cached(distro)?.let { return it } + val size = probe(distro.downloadUrl) + check(preferences.edit().putLong(distro.cacheKey(), size).commit()) { + "Could not cache the Linux image size" + } + return size + } + + private fun probe(url: String): Long { + open(url, "HEAD").useConnection { connection -> + val size = totalBytesFromHeaders( + connection.responseCode, + connection.contentLengthLong, + connection.getHeaderField("Content-Range"), + ) + if (size != null) return size + } + open(url, "GET").apply { + setRequestProperty("Range", "bytes=0-0") + }.useConnection { connection -> + return totalBytesFromHeaders( + connection.responseCode, + connection.contentLengthLong, + connection.getHeaderField("Content-Range"), + ) ?: throw IOException("The image server did not provide a download size") + } + } + + private fun open( + url: String, + method: String, + ): HttpURLConnection = + (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 10_000 + readTimeout = 10_000 + instanceFollowRedirects = true + requestMethod = method + setRequestProperty("Accept-Encoding", "identity") + setRequestProperty("User-Agent", "uDroid-Android/0.1") + } + + private inline fun HttpURLConnection.useConnection(block: (HttpURLConnection) -> T): T = + try { + block(this) + } finally { + disconnect() + } + + private fun DistroVariant.cacheKey(): String = + "${sha256.ifBlank { downloadUrl }}:${architecture}" +} + +internal fun totalBytesFromHeaders( + responseCode: Int, + contentLength: Long, + contentRange: String?, +): Long? { + if (responseCode !in 200..299) return null + val rangedTotal = contentRange?.substringAfterLast('/')?.toLongOrNull() + return rangedTotal?.takeIf { it > 0L } ?: contentLength.takeIf { it > 0L } +} 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 fd7cb62..905af48 100644 --- a/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt +++ b/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt @@ -697,7 +697,12 @@ class InstallerService : Service() { PendingIntent.getActivity( this, 0, - Intent(this, MainActivity::class.java), + Intent(this, MainActivity::class.java) + .setAction(ACTION_SHOW_INSTALLATION) + .putExtra( + EXTRA_INSTALLATION_NAME, + app.installState.current()?.installationName, + ), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val pauseIntent = @@ -1133,6 +1138,9 @@ class InstallerService : Service() { companion object { const val ACTION_STATE_CHANGED = "org.randomcoder.udroid.action.INSTALL_STATE_CHANGED" + const val ACTION_SHOW_INSTALLATION = + "org.randomcoder.udroid.action.SHOW_INSTALLATION" + const val EXTRA_INSTALLATION_NAME = "installation-name" const val EXTRA_STAGE = "stage" const val EXTRA_PERCENTAGE = "percentage" diff --git a/app/src/main/java/org/randomcoder/udroid/install/OciInstallationSelection.kt b/app/src/main/java/org/randomcoder/udroid/install/OciInstallationSelection.kt index de523cd..3ada2ef 100644 --- a/app/src/main/java/org/randomcoder/udroid/install/OciInstallationSelection.kt +++ b/app/src/main/java/org/randomcoder/udroid/install/OciInstallationSelection.kt @@ -39,6 +39,7 @@ object OciInstallationSelection { "[ready] ${formatBytes(tag.compressedBytes)} compressed", ), previewOnly = false, + totalBytes = tag.compressedBytes, ) } diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/InstalledRootfsRegistry.kt b/app/src/main/java/org/randomcoder/udroid/runtime/InstalledRootfsRegistry.kt index 5805711..c4420bc 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/InstalledRootfsRegistry.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/InstalledRootfsRegistry.kt @@ -4,6 +4,12 @@ import android.content.Context import org.randomcoder.udroid.UdroidApplication import org.randomcoder.udroid.install.RootfsInstallationPipeline import java.io.File +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes data class InstalledRootfs( val name: String, @@ -114,3 +120,45 @@ internal object InstalledRootfsDiscovery { .thenBy { it.name }, ).toList() } + +internal object RootfsStorageUsage { + fun calculate(rootfs: File): Long = + calculateWithAndroidDu(rootfs) ?: calculateWithFileTree(rootfs) + + private fun calculateWithAndroidDu(rootfs: File): Long? { + val du = File("/system/bin/du").takeIf(File::canExecute) ?: return null + val process = + ProcessBuilder(du.absolutePath, "-sk", rootfs.absolutePath) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readLines() } + process.waitFor() + return output + .asReversed() + .firstNotNullOfOrNull { line -> + line.trimStart().takeWhile { !it.isWhitespace() }.toLongOrNull() + }?.times(1024L) + } + + private fun calculateWithFileTree(rootfs: File): Long { + var total = 0L + Files.walkFileTree( + rootfs.toPath(), + object : SimpleFileVisitor() { + override fun visitFile( + file: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + if (attrs.isRegularFile) total += attrs.size() + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path, + exc: IOException, + ): FileVisitResult = FileVisitResult.CONTINUE + }, + ) + return total + } +} 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 79c492d..519b4f4 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt @@ -129,7 +129,6 @@ enum class UdroidDestination( ) { HOME("Home", Icons.Rounded.Home, Icons.Rounded.Home), DISTROS("Linux", Icons.Rounded.Storage, Icons.Rounded.Storage), - INSTALL("Install", Icons.Rounded.Storage, Icons.Rounded.Storage), SYSTEM("System", Icons.Rounded.Storage, Icons.Rounded.Storage), ENVIRONMENT("Environment", Icons.Rounded.Code, Icons.Rounded.Code), MOUNTS("Mounts", Icons.Rounded.Tune, Icons.Rounded.Tune), @@ -163,7 +162,6 @@ internal fun navigationMotion( private val UdroidDestination.navigationDepth: Int get() = when (this) { - UdroidDestination.INSTALL, UdroidDestination.SYSTEM, -> 1 UdroidDestination.MOUNTS, @@ -191,6 +189,8 @@ fun UdroidApp( selectedSystemRootfsName: String?, rootfsMaintenanceName: String?, rootfsMaintenanceMessage: String?, + rootfsOccupiedBytes: Long?, + rootfsStorageLoading: Boolean, desktopEnvironments: List, desktopConfiguration: DesktopConfiguration, desktopScanLoading: Boolean, @@ -213,6 +213,7 @@ fun UdroidApp( onBackFromOciRepository: () -> Unit, onSelectOciTag: (OciHubRepository, OciHubTagPlatform) -> Unit, onOpenInstalledSystem: (String) -> Unit, + onOpenInstallation: (String) -> Unit, onOpenRootfsTerminal: (String) -> Unit, onOpenRootfsApps: (String) -> Unit, onResetRootfs: (String, DistroVariant?) -> Unit, @@ -255,7 +256,6 @@ fun UdroidApp( val activeDestination = requestedJourney.destination val navigationDestination = when (activeDestination) { - UdroidDestination.INSTALL, UdroidDestination.SYSTEM, -> UdroidDestination.DISTROS UdroidDestination.MOUNTS, @@ -342,6 +342,8 @@ fun UdroidApp( selectedSystemRootfsName = selectedSystemRootfsName, rootfsMaintenanceName = rootfsMaintenanceName, rootfsMaintenanceMessage = rootfsMaintenanceMessage, + rootfsOccupiedBytes = rootfsOccupiedBytes, + rootfsStorageLoading = rootfsStorageLoading, desktopEnvironments = desktopEnvironments, desktopConfiguration = desktopConfiguration, desktopScanLoading = desktopScanLoading, @@ -365,6 +367,7 @@ fun UdroidApp( onBackFromOciRepository = onBackFromOciRepository, onSelectOciTag = onSelectOciTag, onOpenInstalledSystem = onOpenInstalledSystem, + onOpenInstallation = onOpenInstallation, onOpenRootfsTerminal = onOpenRootfsTerminal, onOpenRootfsApps = onOpenRootfsApps, onSelectMountProfile = { systemId -> @@ -428,6 +431,8 @@ fun UdroidApp( selectedSystemRootfsName = selectedSystemRootfsName, rootfsMaintenanceName = rootfsMaintenanceName, rootfsMaintenanceMessage = rootfsMaintenanceMessage, + rootfsOccupiedBytes = rootfsOccupiedBytes, + rootfsStorageLoading = rootfsStorageLoading, desktopEnvironments = desktopEnvironments, desktopConfiguration = desktopConfiguration, desktopScanLoading = desktopScanLoading, @@ -451,6 +456,7 @@ fun UdroidApp( onBackFromOciRepository = onBackFromOciRepository, onSelectOciTag = onSelectOciTag, onOpenInstalledSystem = onOpenInstalledSystem, + onOpenInstallation = onOpenInstallation, onOpenRootfsTerminal = onOpenRootfsTerminal, onOpenRootfsApps = onOpenRootfsApps, onSelectMountProfile = { systemId -> @@ -530,6 +536,8 @@ private fun ManagementPane( selectedSystemRootfsName: String?, rootfsMaintenanceName: String?, rootfsMaintenanceMessage: String?, + rootfsOccupiedBytes: Long?, + rootfsStorageLoading: Boolean, desktopEnvironments: List, desktopConfiguration: DesktopConfiguration, desktopScanLoading: Boolean, @@ -553,6 +561,7 @@ private fun ManagementPane( onBackFromOciRepository: () -> Unit, onSelectOciTag: (OciHubRepository, OciHubTagPlatform) -> Unit, onOpenInstalledSystem: (String) -> Unit, + onOpenInstallation: (String) -> Unit, onOpenRootfsTerminal: (String) -> Unit, onOpenRootfsApps: (String) -> Unit, onSelectMountProfile: (String) -> Unit, @@ -835,39 +844,39 @@ private fun ManagementPane( ) } ?: DistroCataloguePage( state = catalogueState, - ociState = ociCatalogueState, installedRootfses = installedRootfses, activeRootfsName = installedRootfsName, + installProgress = installProgress, onRetry = onReloadCatalogue, onPreviewInstall = onPreviewInstall, - onSelectOciRepository = onSelectOciRepository, onOpenInstalledSystem = onOpenInstalledSystem, + onOpenInstallation = onOpenInstallation, ) - UdroidDestination.INSTALL -> - installProgress?.let { + UdroidDestination.SYSTEM -> { + val rootfsName = selectedSystemRootfsName ?: installedRootfsName + val selectedRootfs = + installedRootfses.firstOrNull { it.name == rootfsName } + val selectedDistro = + (catalogueState as? DistroCatalogState.Ready) + ?.catalog + ?.variants + ?.firstOrNull { it.internalName == rootfsName } + val selectedInstallation = + installProgress?.takeIf { it.installationName == rootfsName } + if (selectedRootfs == null && selectedInstallation != null) { InstallExperiencePage( - progress = it, + progress = selectedInstallation, showTerminal = showInstallTerminal, onToggleTerminal = onToggleInstallTerminal, onBack = onCloseInstall, onOpenTerminal = { - onOpenRootfsTerminal(it.installationName) + onOpenRootfsTerminal(selectedInstallation.installationName) }, onStartDownload = onStartDownload, onPauseDownload = onPauseDownload, onRetryDownload = onRetryDownload, ) - } - UdroidDestination.SYSTEM -> { - val rootfsName = selectedSystemRootfsName ?: installedRootfsName - val selectedRootfs = - installedRootfses.firstOrNull { it.name == rootfsName } - val selectedDistro = - (catalogueState as? DistroCatalogState.Ready) - ?.catalog - ?.variants - ?.firstOrNull { it.internalName == rootfsName } - if (selectedRootfs == null) { + } else if (selectedRootfs == null) { onDestinationSelected(UdroidDestination.DISTROS) } else { val context = LocalContext.current @@ -906,6 +915,8 @@ private fun ManagementPane( rootfsMaintenanceName == null || rootfsMaintenanceName == selectedRootfs.name }, + occupiedBytes = rootfsOccupiedBytes, + storageLoading = rootfsStorageLoading, onBack = { onDestinationSelected(UdroidDestination.DISTROS) }, 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 dfad743..36dba7f 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/DistroScreens.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/DistroScreens.kt @@ -76,7 +76,6 @@ import org.randomcoder.udroid.install.InstallProgress import org.randomcoder.udroid.install.InstallStage import org.randomcoder.udroid.install.InstallerWorkRequest import org.randomcoder.udroid.install.OciInstallationSelection -import org.randomcoder.udroid.oci.OciHubCatalogueState import org.randomcoder.udroid.oci.OciHubRepository import org.randomcoder.udroid.oci.OciHubTagPlatform import org.randomcoder.udroid.oci.OciHubTagsState @@ -94,13 +93,13 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) fun DistroCataloguePage( state: DistroCatalogState, - ociState: OciHubCatalogueState, installedRootfses: List, activeRootfsName: String?, + installProgress: InstallProgress?, onRetry: () -> Unit, onPreviewInstall: (DistroVariant) -> Unit, - onSelectOciRepository: (OciHubRepository) -> Unit, onOpenInstalledSystem: (String) -> Unit, + onOpenInstallation: (String) -> Unit, ) { when (state) { DistroCatalogState.Loading -> { @@ -151,24 +150,26 @@ fun DistroCataloguePage( is DistroCatalogState.Ready -> { val catalogue = state.catalog - val ociReady = ociState as? OciHubCatalogueState.Ready - val ociRepositories = ociReady?.snapshot?.repositories.orEmpty() val installedNames = remember(installedRootfses) { installedRootfses.mapTo(mutableSetOf(), InstalledRootfs::name) } + val variantsByName = + remember(catalogue.variants) { + catalogue.variants.associateBy(DistroVariant::internalName) + } + val activeInstallation = + installProgress?.takeUnless { it.installationName in installedNames } val orderedVariants = - remember(catalogue.variants, installedNames, activeRootfsName) { - catalogue.variants.sortedWith( - compareBy { distro -> - when { - distro.internalName == activeRootfsName -> 0 - distro.internalName in installedNames -> 1 - distro.recommended -> 2 - else -> 3 - } - }.thenBy { it.releaseName.lowercase() }, - ) + remember(catalogue.variants, installedNames, activeInstallation) { + catalogue.variants + .filterNot { + it.internalName in installedNames || + it.internalName == activeInstallation?.installationName + }.sortedWith( + compareByDescending(DistroVariant::recommended) + .thenBy { it.releaseName.lowercase() }, + ) } var searchQuery by remember(catalogue.architecture) { mutableStateOf("") } val visibleVariants by @@ -189,21 +190,32 @@ fun DistroCataloguePage( } } } - val visibleOciRepositories by - remember(ociRepositories, searchQuery) { - derivedStateOf { - val terms = searchTerms(searchQuery) - if (terms.isEmpty()) { - ociRepositories - } else { - ociRepositories.filter { repository -> - terms.all(repository.searchableText()::contains) - } - } - } + val terms = searchTerms(searchQuery) + val visibleInstalled = + installedRootfses.filter { rootfs -> + val distro = variantsByName[rootfs.name] + val searchable = + listOf( + rootfs.name, + distro?.searchableText.orEmpty(), + ).joinToString(" ").lowercase(Locale.US) + terms.all(searchable::contains) + } + val installationVisible = + activeInstallation?.takeIf { progress -> + val searchable = + listOf( + progress.displayName, + progress.installationName, + progress.sourceIdentity, + ).joinToString(" ").lowercase(Locale.US) + terms.all(searchable::contains) } - val visibleCount = visibleVariants.size + visibleOciRepositories.size - val totalCount = catalogue.variants.size + ociRepositories.size + val visibleCount = + visibleInstalled.size + + visibleVariants.size + + if (installationVisible == null) 0 else 1 + val totalCount = catalogue.variants.size LazyColumn( modifier = @@ -268,10 +280,7 @@ fun DistroCataloguePage( ) } - if ( - visibleCount == 0 && - ociState !is OciHubCatalogueState.Loading - ) { + if (visibleCount == 0) { item(key = "empty-search") { Surface( color = UdroidRaised, @@ -293,86 +302,66 @@ fun DistroCataloguePage( } } - if (visibleVariants.isNotEmpty()) { - item(key = "archive-sources") { + installationVisible?.let { progress -> + item(key = "active-installation") { UdroidSectionLabel( - text = "uDroid and proot-distro", + text = "Installing", modifier = Modifier.padding(top = 3.dp), ) } - items( - items = visibleVariants, - key = { it.id }, - contentType = { "distro-card" }, - ) { distro -> - val installed = distro.internalName in installedNames - DistroCard( - distro = distro, - installed = installed, - active = distro.internalName == activeRootfsName, - onSelect = { - if (installed) { - onOpenInstalledSystem(distro.internalName) - } else { - onPreviewInstall(distro) - } - }, + item(key = "install:${progress.operationId}") { + InstallationCatalogueCard( + progress = progress, + onSelect = { onOpenInstallation(progress.installationName) }, ) } } - if ( - searchQuery.isBlank() || - visibleOciRepositories.isNotEmpty() || - ociState !is OciHubCatalogueState.Ready - ) { - item(key = "oci-source") { + if (visibleInstalled.isNotEmpty()) { + item(key = "installed-systems") { UdroidSectionLabel( - text = "More official images", - modifier = Modifier.padding(top = 8.dp), + text = "Installed", + modifier = Modifier.padding(top = 3.dp), ) } - } - when (ociState) { - OciHubCatalogueState.Loading -> { - item(key = "oci-loading") { - InlineCatalogueStatus( - loading = true, - title = "Finding more Linux systems", - detail = "Systems listed above are still available", + items( + items = visibleInstalled, + key = { "installed:${it.name}" }, + contentType = { "installed-distro-card" }, + ) { rootfs -> + variantsByName[rootfs.name]?.let { distro -> + DistroCard( + distro = distro, + installed = true, + active = rootfs.name == activeRootfsName, + onSelect = { onOpenInstalledSystem(rootfs.name) }, ) - } + } ?: InstalledRootfsCard( + rootfs = rootfs, + active = rootfs.name == activeRootfsName, + onSelect = { onOpenInstalledSystem(rootfs.name) }, + ) } + } - is OciHubCatalogueState.Failed -> { - item(key = "oci-failed") { - InlineCatalogueStatus( - loading = false, - title = "Can’t load more Linux systems", - detail = ociState.message, - actionLabel = "Retry", - onAction = onRetry, - ) - } + if (visibleVariants.isNotEmpty()) { + item(key = "archive-sources") { + UdroidSectionLabel( + text = "Available from uDroid and proot-distro", + modifier = Modifier.padding(top = 3.dp), + ) } - - is OciHubCatalogueState.Ready -> { - items( - items = visibleOciRepositories, - key = { "oci:${it.name}" }, - contentType = { "oci-repository-card" }, - ) { repository -> - val installed = - installedNames.any { - it.startsWith("oci-${repository.name}-") - } - OciRepositoryCard( - repository = repository, - architecture = ociState.platform.displayArchitecture(), - installed = installed, - onSelect = { onSelectOciRepository(repository) }, - ) - } + items( + items = visibleVariants, + key = { it.id }, + contentType = { "distro-card" }, + ) { distro -> + DistroCard( + distro = distro, + installed = false, + active = false, + onSelect = { onPreviewInstall(distro) }, + ) } } @@ -469,54 +458,72 @@ private fun DistroCard( } @Composable -private fun OciRepositoryCard( - repository: OciHubRepository, - architecture: String, - installed: Boolean, +private fun InstallationCatalogueCard( + progress: InstallProgress, onSelect: () -> Unit, ) { - val title = OciInstallationSelection.displayName(repository) OutlinedCard( onClick = onSelect, modifier = Modifier.fillMaxWidth(), colors = CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + containerColor = MaterialTheme.colorScheme.primaryContainer, ), ) { - ListItem( - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - leadingContent = { OciRepositoryMark(repository) }, - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(progress.displayName, style = MaterialTheme.typography.titleMedium) Text( - title, - style = MaterialTheme.typography.titleMedium, + progress.stage.normalTitle, + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.bodySmall, ) - if (installed) { - Spacer(Modifier.size(8.dp)) - UdroidStatusBadge( - label = "Installed", - color = UdroidForest, - background = UdroidSoftGreen, - ) - } } - }, - supportingContent = { Text( - "Official image · $architecture · Choose version", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.labelSmall, + if (progress.stage == InstallStage.READY) "Review" else "${progress.percentage}%", + style = MaterialTheme.typography.labelLarge, ) + Icon(Icons.Rounded.ChevronRight, contentDescription = "Open installation") + } + Spacer(Modifier.height(10.dp)) + LinearProgressIndicator( + progress = { progress.overallProgress }, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun InstalledRootfsCard( + rootfs: InstalledRootfs, + active: Boolean, + onSelect: () -> Unit, +) { + OutlinedCard( + onClick = onSelect, + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.outlinedCardColors( + containerColor = + if (active) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + ListItem( + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + headlineContent = { + Text(rootfs.name, style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + Text(if (active) "Active · Installed" else "Installed") }, trailingContent = { - Icon( - imageVector = Icons.Rounded.ChevronRight, - contentDescription = "Choose a $title version", - tint = UdroidFaint, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Open", color = UdroidForest, style = MaterialTheme.typography.labelLarge) + Icon(Icons.Rounded.ChevronRight, contentDescription = "Open ${rootfs.name}") + } }, ) } @@ -833,14 +840,6 @@ private fun searchTerms(query: String): List = .split(Regex("\\s+")) .filter(String::isNotBlank) -private fun OciHubRepository.searchableText(): String = - listOf( - name, - OciInstallationSelection.displayName(this), - description, - "official container image docker hub OCI", - ).joinToString(" ").lowercase(Locale.US) - private fun OciPlatform.displayArchitecture(): String = when (architecture) { "arm64" -> "aarch64" @@ -851,7 +850,7 @@ private fun OciPlatform.displayArchitecture(): String = private fun OciPlatform.displayLabel(): String = listOfNotNull(os, displayArchitecture(), variant).joinToString("/") -private fun formatCompactBytes(bytes: Long): String = +internal fun formatCompactBytes(bytes: Long): String = when { bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GiB", bytes / (1024.0 * 1024.0 * 1024.0)) @@ -949,6 +948,30 @@ fun InstallExperiencePage( } } + item { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Download size", style = MaterialTheme.typography.bodyMedium) + Text( + when { + progress.totalBytes > 0L -> formatCompactBytes(progress.totalBytes) + progress.totalBytes == 0L -> "Unavailable" + else -> "Checking…" + }, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + if (progress.previewOnly) { item { Surface( 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 31ae0cc..818fc03 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt @@ -97,6 +97,8 @@ fun LinuxSystemPage( resetAvailable: Boolean, maintenanceInProgress: Boolean, maintenanceMessage: String?, + occupiedBytes: Long?, + storageLoading: Boolean, onBack: () -> Unit, onOpenTerminal: () -> Unit, onOpenApps: () -> Unit, @@ -462,7 +464,14 @@ fun LinuxSystemPage( shape = MaterialTheme.shapes.medium, ) { Column { - FactRow("Storage", rootfs.directory.name) + FactRow( + "Space used", + when { + storageLoading -> "Calculating…" + occupiedBytes != null -> formatCompactBytes(occupiedBytes) + else -> "Unavailable" + }, + ) HorizontalDivider(color = UdroidLine) FactRow( "Installed", 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 391c4c6..0891f4d 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/WorkspaceJourney.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/WorkspaceJourney.kt @@ -28,7 +28,9 @@ fun workspaceJourney( when { !hasInstalledLinux && requestedDestination.requiresInstalledLinux -> UdroidDestination.DISTROS - requestedDestination == UdroidDestination.INSTALL && !hasInstallation -> + requestedDestination == UdroidDestination.SYSTEM && + !hasInstalledLinux && + !hasInstallation -> UdroidDestination.DISTROS else -> requestedDestination } @@ -36,7 +38,6 @@ fun workspaceJourney( if (hasInstalledLinux) { UdroidDestination.entries.filterNot { it == UdroidDestination.SYSTEM || - it == UdroidDestination.INSTALL || it == UdroidDestination.MOUNTS || it == UdroidDestination.MOUNT_EDITOR || it == UdroidDestination.ENVIRONMENT || @@ -61,7 +62,6 @@ fun workspaceJourney( val UdroidDestination.requiresInstalledLinux: Boolean get() = this == UdroidDestination.TERMINAL || - this == UdroidDestination.SYSTEM || this == UdroidDestination.MOUNTS || this == UdroidDestination.MOUNT_EDITOR || this == UdroidDestination.ENVIRONMENT || diff --git a/app/src/test/java/org/randomcoder/udroid/catalog/DistroArchiveSizeStoreTest.kt b/app/src/test/java/org/randomcoder/udroid/catalog/DistroArchiveSizeStoreTest.kt new file mode 100644 index 0000000..0e1e9b7 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/catalog/DistroArchiveSizeStoreTest.kt @@ -0,0 +1,14 @@ +package org.randomcoder.udroid.catalog + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DistroArchiveSizeStoreTest { + @Test + fun `size headers prefer the complete ranged total`() { + assertEquals(172L, totalBytesFromHeaders(206, 1L, "bytes 0-0/172")) + assertEquals(172L, totalBytesFromHeaders(200, 172L, null)) + assertNull(totalBytesFromHeaders(404, 172L, null)) + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/RootfsStorageUsageTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/RootfsStorageUsageTest.kt new file mode 100644 index 0000000..ecf3b4c --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/RootfsStorageUsageTest.kt @@ -0,0 +1,24 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.nio.file.Files + +class RootfsStorageUsageTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `counts regular rootfs files`() { + val rootfs = temporaryFolder.newFolder("rootfs") + rootfs.resolve("etc").mkdir() + rootfs.resolve("etc/config").writeBytes(ByteArray(7)) + rootfs.resolve("payload").writeBytes(ByteArray(13)) + val outside = temporaryFolder.newFile("outside").apply { writeBytes(ByteArray(100)) } + Files.createSymbolicLink(rootfs.resolve("outside-link").toPath(), outside.toPath()) + + assertEquals(20L, RootfsStorageUsage.calculate(rootfs)) + } +} 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 277084e..8f59449 100644 --- a/app/src/test/java/org/randomcoder/udroid/ui/WorkspaceJourneyTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/ui/WorkspaceJourneyTest.kt @@ -45,7 +45,6 @@ class WorkspaceJourneyTest { assertTrue(journey.destinations.contains(UdroidDestination.TERMINAL)) assertTrue(journey.destinations.contains(UdroidDestination.APPS)) assertFalse(journey.destinations.contains(UdroidDestination.SYSTEM)) - assertFalse(journey.destinations.contains(UdroidDestination.INSTALL)) assertFalse(journey.destinations.contains(UdroidDestination.DESKTOP)) assertFalse(journey.destinations.contains(UdroidDestination.ENVIRONMENT)) } @@ -106,26 +105,40 @@ class WorkspaceJourneyTest { } @Test - fun `installation is a nested route and never a navigation tab`() { + fun `suite detail is a nested route and never a navigation tab`() { val journey = workspaceJourney( - requestedDestination = UdroidDestination.INSTALL, + requestedDestination = UdroidDestination.SYSTEM, hasInstalledLinux = true, hasInstallation = true, compactNavigation = true, ) - assertEquals(UdroidDestination.INSTALL, journey.destination) - assertFalse(journey.destinations.contains(UdroidDestination.INSTALL)) + assertEquals(UdroidDestination.SYSTEM, journey.destination) + assertFalse(journey.destinations.contains(UdroidDestination.SYSTEM)) assertTrue(journey.destinations.contains(UdroidDestination.DISTROS)) } @Test - fun `stale installation route returns to the Linux catalogue`() { + fun `suite detail keeps an active installation on its persistent page`() { val journey = workspaceJourney( - requestedDestination = UdroidDestination.INSTALL, - hasInstalledLinux = true, + requestedDestination = UdroidDestination.SYSTEM, + hasInstalledLinux = false, + hasInstallation = true, + compactNavigation = true, + ) + + assertEquals(UdroidDestination.SYSTEM, journey.destination) + assertFalse(journey.destinations.contains(UdroidDestination.SYSTEM)) + } + + @Test + fun `suite detail without a system or installation returns to catalogue`() { + val journey = + workspaceJourney( + requestedDestination = UdroidDestination.SYSTEM, + hasInstalledLinux = false, hasInstallation = false, compactNavigation = true, ) @@ -139,10 +152,6 @@ class WorkspaceJourneyTest { NavigationMotion.FORWARD, navigationMotion(UdroidDestination.DISTROS, UdroidDestination.SYSTEM), ) - assertEquals( - NavigationMotion.FORWARD, - navigationMotion(UdroidDestination.DISTROS, UdroidDestination.INSTALL), - ) assertEquals( NavigationMotion.BACK, navigationMotion(UdroidDestination.SYSTEM, UdroidDestination.DISTROS), diff --git a/docs/APP_UPDATES.md b/docs/APP_UPDATES.md index 76ff7de..17edf48 100644 --- a/docs/APP_UPDATES.md +++ b/docs/APP_UPDATES.md @@ -49,7 +49,8 @@ Redirects may remain on HTTPS but cannot downgrade to cleartext HTTP. Android updates must use the same signing certificate as the installed app. Tagged CI builds therefore fail closed unless the four update-signing secrets -listed in the README are configured. Normal branch and pull-request builds can +listed in [Development](DEVELOPMENT.md) are configured. Normal branch and +pull-request builds can continue using Android's local debug key because they are not published as updates. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..c71e570 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,76 @@ +# Development + +## CI builds and releases + +Git tags matching `v*` are built by GitHub Actions. Each prerelease contains: + +- one universal, optimized APK for `arm64-v8a`, `armeabi-v7a`, and `x86_64`, + signed with the project update key; +- `SHA256SUMS` for the APK; +- GitHub source archives with vendored Termux components and third-party + notices. + +Pull requests and non-tag workflow runs publish an optimized `uDroid Dev` APK +for 30 days. It uses the `org.randomcoder.udroid.dev` application ID and the +Android debug key, so it can be installed beside the update-signed app. PR +artifacts include the pull request number and head commit, for example +`udroid-pr-24-cf9c1ed-dev.apk`. These artifacts cannot update the official app +or a debug build signed on another machine. + +The published `v0.0.2` APK predates stable update signing and retains its +original debug asset name: + +```sh +adb install -r udroid-v0.0.2-debug.apk +``` + +Tagged releases require these GitHub Actions secrets: + +- `UDROID_SIGNING_STORE_BASE64` +- `UDROID_SIGNING_STORE_PASSWORD` +- `UDROID_SIGNING_KEY_ALIAS` +- `UDROID_SIGNING_KEY_PASSWORD` + +The first stable-signed build cannot replace an older ephemeral-debug-signed +APK. Testers must reinstall once; later builds signed with the same key can use +the in-app updater. + +## Build locally + +Requirements: + +- JDK 17 or newer +- Android SDK platform 36 +- Android NDK 28.2 (runtime probes also build with NDK 26 or newer) + +```sh +export ANDROID_HOME="$HOME/Library/Android/sdk" +export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/28.2.13676358" +./app/src/main/cpp/build-runtime-probe.sh +./tools/build-proot-assets.sh +./tools/build-gnu-tar-assets.sh +./tools/build-pulseaudio-assets.sh +./gradlew :app:assembleRelease +``` + +The app targets API 36. On Android 10 and newer it launches packaged Android +ELFs through `/system/bin/linker(64)`. PRoot's static guest loader is installed +as an extracted APK native library so Android can execute its second hop. + +UI performance is measured from optimized builds with device Macrobenchmarks, +Perfetto traces, and generated Baseline Profiles. See +[Performance](PERFORMANCE.md) for commands and current results. + +## Licensing and redistribution + +The uDroid-owned Android shell is MIT-licensed, matching `fs-manager-udroid`. +Packaged PRoot is GPL-2.0 and statically links LGPL-3.0-or-later talloc. The +rootfs installer packages GNU tar and Termux's BSD-licensed libandroid-glob. +The vendored Termux terminal components use Apache-2.0 under Termux's upstream +license exception. The embedded Termux:X11 module is GPLv3, so APKs containing +it are distributed as GPLv3 combined works. + +Exact source versions, checksums, patches, and build commands are recorded in +`tools/` and `third_party/`. Binary releases must provide the applicable source +and license texts. See the repository [license](../LICENSE) and +[third-party notices](../THIRD_PARTY_NOTICES.md). diff --git a/docs/DISTRIBUTION_CATALOGUE.md b/docs/DISTRIBUTION_CATALOGUE.md index 2819cb6..d63735a 100644 --- a/docs/DISTRIBUTION_CATALOGUE.md +++ b/docs/DISTRIBUTION_CATALOGUE.md @@ -41,21 +41,6 @@ was marked broken on Android 15 and newer. An image is not shown merely because an archive exists; its architecture, checksum, extraction layout, and PRoot startup must be understood first. -## Official container images - -The same searchable list also includes active operating-system repositories -from Docker Hub's official `library` namespace. Selecting one opens a version -page containing only tags that publish an image for the phone's OCI platform. -The review page records the selected platform, compressed size, tag, and -immutable manifest digest before installation begins. - -Container images follow a separate verified OCI pipeline; they are not passed -to Docker, containerd, or a daemon. uDroid resolves the manifest, downloads and -verifies each referenced blob, applies ordered filesystem layers and OCI -whiteouts in app-private staging storage, adds Android/PRoot compatibility -files, executes the standard rootfs health probe, and only then activates the -installation. See [OCI image architecture](OCI_IMAGES.md). - ## Visual identity Distribution marks are packaged VectorDrawables derived from the Simple Icons diff --git a/docs/OCI_IMAGES.md b/docs/OCI_IMAGES.md deleted file mode 100644 index b828d16..0000000 --- a/docs/OCI_IMAGES.md +++ /dev/null @@ -1,155 +0,0 @@ -# OCI images - -uDroid can install official operating-system container images as ordinary -PRoot root filesystems. This path does not run Docker, containerd, a privileged -daemon, namespaces, or a nested container runtime. The app implements the -small OCI distribution and layer-processing surface needed to produce a -verified rootfs in app-private storage. - -The first release of this path deliberately limits discovery to active -operating-system images in Docker Hub's official `library` namespace. A -repository appearing in search is not enough to start a download: the user -must select a tag that has a compatible image for the phone architecture and -review its size and source first. - -## Installation path - -```mermaid -flowchart TD - A["Docker Hub official OS catalogue"] --> B["Compatible repository and tag"] - B --> C["Tag plus immutable SHA-256 digest"] - C --> D["OCI registry manifest resolution"] - D --> E["Architecture-specific manifest"] - E --> F["Resumable config and layer downloads"] - F --> G["SHA-256 verification"] - G --> H["Ordered layer and whiteout application"] - H --> I["Android and PRoot compatibility files"] - I --> J["First-boot health probe"] - J --> K["Atomic rootfs activation"] -``` - -The UI stores both the human-facing tag and its platform digest. Installation -therefore resolves a reference such as: - -```text -docker.io/library/almalinux:latest@sha256: -``` - -The tag remains useful to the user, while the digest prevents a mutable tag -from silently selecting different content between review and installation. - -## Discovery and caching - -Repository discovery accepts only entries that: - -- belong to Docker Hub's official `library` namespace; -- are active image repositories in the operating-systems category; -- use a validated repository name; -- are not described as deprecated. - -Tag discovery filters the returned platform descriptors against Android's -supported ABI. `arm64-v8a`, for example, maps to OCI `linux/arm64/v8`. -Repository metadata is cached for six hours and tag metadata for two hours. -When refresh fails, an existing validated cache can be shown as stale data -instead of removing the catalogue. Activity recreation also retains the -selected repository and reloads its tag state. - -The catalogue is intentionally not a general Docker Hub search. Community -images, arbitrary registries, private credentials, and user-provided image -references need a separate trust and UX design. - -## Registry and download boundaries - -The registry client supports OCI and Docker v2 image indexes and manifests. It -selects the requested OS, architecture, and variant, limits metadata response -sizes, and verifies manifest digests supplied by both the selected descriptor -and the registry response. - -Anonymous Bearer challenges are supported over HTTPS. The returned token is -used only for requests to the original blob origin. The shared resumable -artifact pipeline rejects unsafe header names and values, controls Range -requests itself, and does not forward authorization headers across a -cross-origin redirect. - -Each config or filesystem blob is written to a partial file, resumed when -possible, checked against its descriptor's SHA-256 digest, and atomically -promoted to the verified blob cache. Interrupted verified data can be reused -by a later attempt. Verified blobs are removed after a rootfs is activated. - -## Layers, whiteouts, and activation - -OCI filesystem layers are applied in manifest order. Before extracting a -layer, uDroid scans its tar entry names and applies: - -- `.wh.` as deletion of the lower-layer path; -- `.wh..wh..opq` as removal of lower-layer children in that directory. - -Whiteout marker files are excluded from normal tar extraction. Every planned -path is confined to the staging rootfs, so a malformed layer cannot use -whiteouts to delete app data outside that directory. - -Tar entry-name scanning supports ustar prefixes, POSIX PAX paths, GNU long -names, checksum validation, octal sizes, and positive base-256 sizes. PAX -records may also contain binary extended attributes. The scanner decodes the -record key first and decodes a value as UTF-8 only when the key is `path`. -This matters for AlmaLinux, whose official layer contains binary -`SCHILY.xattr.security.capability` data that is valid tar metadata but is not -UTF-8 text. - -Extraction occurs in an operation-owned staging directory. On success uDroid: - -1. writes Android DNS, identity, mount, and PRoot compatibility files; -2. executes the same mounted first-boot health probe used by archive installs; -3. writes the ready marker; -4. atomically renames staging storage to the final installation name. - -Failed staging data is removed only when its ownership marker matches the -current OCI installation. An unrelated or unrecognized directory is left -untouched. - -## State and progress - -Archive and OCI installations share one persisted `InstallerWorkRequest` -contract and the same foreground installer service. The state snapshot records -the immutable image reference, target platform, installation name, operation -identifier, progress stage, byte totals, terminal transcript, and whether the -operation can be cancelled. - -Older archive-only snapshots are migrated when read. OCI progress survives -Activity recreation and process interruption, and the UI reattaches to the -same saved operation rather than creating another installation implicitly. - -## Validation - -The implementation has unit coverage for: - -- image-reference parsing and normalization; -- manifest-list platform selection and digest verification; -- Bearer authentication and origin-confined request headers; -- resumable aggregate progress; -- Docker Hub catalogue and tag parsing, caching, and stale fallback; -- whiteout planning and path confinement; -- tar checksums, PAX paths, GNU long names, and binary PAX attributes; -- transactional activation, failure cleanup, persisted progress, and legacy - state migration. - -Android probe tests use the separate -`org.randomcoder.udroid.ociprobe` application ID so destructive rootfs tests -cannot uninstall or overwrite the normal uDroid package. The real-device -checkpoint installed the official arm64 AlmaLinux `latest` layer end to end, -including compatibility configuration and the first-boot health probe. - -## Current limitations - -- Discovery is restricted to anonymous official Docker Hub operating-system - images. -- Only OCI/Docker image indexes, manifests, gzip tar layers, and uncompressed - tar layers are accepted. -- Signatures, attestations, SBOM policy, private registries, and registry - credentials are not implemented. -- A container image is a minimal rootfs, not a complete machine boot. Services - that require a real kernel, systemd/logind, privileged mounts, or namespaces - still need PRoot-specific handling. -- An official image can still contain userspace assumptions that do not work - under Android. Digest verification proves content identity, while the - first-boot probe establishes only the baseline execution contract. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..6bc82a8 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,143 @@ +# Using uDroid + +uDroid installs and runs Linux systems from one Android app. This guide covers +the everyday controls and when to use them. + +## Main areas + +| Area | Use it for | +| --- | --- | +| **Home** | See the active system and open common actions. | +| **Linux** | Install, open, reset, or delete Linux systems. | +| **Terminal** | Run Linux commands and install packages. | +| **Apps** | Open graphical or terminal applications installed in Linux. | +| **About** | Check for updates, get support, and copy diagnostic logs. | + +Some areas appear only after a Linux system is installed. +**Device compatibility** on Home shows which optional features are available +on the phone and is useful before changing graphics settings. + +## Install a Linux system + +1. Open **Linux** and choose a distribution. +2. Review its download size and file access. +3. Select **Download image**. +4. Follow the installation stages until **Linux is ready**. +5. Select **Open terminal**. + +You can leave the page while installation continues. The notification returns +to the same installation page. A paused download can be resumed without +starting again. + +If installation stops, read the message below the active stage and open +**View install log** for the full error. Fix the reported issue, then select +**Try again**. + +## Use an installed system + +Open **Linux**, then select an installed system. Its page contains the controls +and settings for that system. + +### Terminal + +Use **Terminal** to install packages, edit files, or run command-line tools. +The terminal stays alive when you move between uDroid pages. Use **Stop** on the +system page when you want to end Linux or change settings that require it to be +stopped. + +### Desktop + +uDroid can start X11 desktop sessions installed inside Linux. + +1. Install XFCE, Plasma, MATE, or another X11 desktop with the distribution's + package manager. +2. Return to the system page and refresh it. +3. Choose the detected desktop and select **Start**. +4. Select **Display** after the desktop is running. + +Use **Desktop compositing** for effects and transparency. Turn it off when a +desktop feels slow or shows visual glitches. **Touch-sized interface** makes +desktop controls and the pointer easier to use on a phone. + +Leave **Graphics driver** on **Automatic** unless an application needs another +available profile. Compatibility is checked per device, and experimental +profiles may be unstable. + +### Linux apps + +The **Apps** page lists launchable applications that provide a standard Linux +desktop entry. Install an application inside Linux, refresh the list, then +select it to launch. + +Use **Add to home screen** when you want an Android launcher shortcut. The +shortcut always points to that application in that specific Linux system. + +## Control the desktop + +The desktop toolbar opens the keyboard, mouse controls, and display settings. + +- **Direct** touch moves the pointer to your finger. +- **Trackpad** touch moves the pointer relative to your finger and is useful + for precise desktop work. +- **Native** touch sends multiple touch points directly to Linux applications + that support them. +- The mouse palette provides left, middle, right, and scroll controls. Hold a + button while moving another finger on the display to drag. + +Display settings can change resolution, scaling, sharpness, pointer speed, +keyboard handling, and whether the screen stays awake. + +## Audio and microphone + +**Device speaker** sends Linux audio to Android and is enabled by default. +**Device microphone** is optional and stored per Linux system. Android asks for +permission and shows its privacy indicator while the microphone is active. + +Applications need PulseAudio-compatible client libraries. If one application +has no sound while others work, install its distribution's PulseAudio client +or compatibility package. + +## Files and environment variables + +Open **File access** to choose which Android and system paths Linux can see. +Internal shared storage can be exposed at `/mnt/shared`. Custom mounts are for +other Android paths the app can read. + +Stop Linux before changing mounts. Session mounts used by the display, audio, +or graphics features are added automatically only when those features need +them. + +Open **Environment variables** to view or change the values used by new +terminal, app, and desktop launches. Restart Linux when an existing terminal +needs the new values. **Reset** restores uDroid-managed values without removing +your custom variables. + +## Reset or delete a system + +Stop its terminal and desktop first. + +- **Reset** erases the Linux filesystem and installs a fresh copy. +- **Delete** removes the Linux system without reinstalling it. + +Android folders shared with Linux are outside its private filesystem and are +not erased by reset or delete. + +## Updates and diagnostics + +Open **About** to check for updates. uDroid verifies a downloaded APK before +opening Android's installer; Android always asks for final confirmation. + +The diagnostic log records app actions and supervised Linux commands. Search +inside it, select a log block to copy that block, or use **Copy report** when +asking for help. + +## When something goes wrong + +| Problem | What to try | +| --- | --- | +| Installation stopped | Read the active-stage message, open the install log, then retry. | +| Android closes Linux | Follow the warning on the system page and review Android's child-process setting. | +| Desktop is slow or damaged | Use **Automatic** or **Software** graphics, disable compositing, or lower the display resolution. | +| An installed app is missing | Refresh **Apps** and check that the package provides a desktop entry. | +| One app has no sound | Install its PulseAudio client or compatibility package. | +| You need help | Copy the diagnostic report from **About** and attach it to a GitHub issue. |