From 4813ed9e99be8126e0270c93b0cd28e845cba419 Mon Sep 17 00:00:00 2001 From: DaRipper Date: Mon, 20 Jul 2026 22:23:47 -0500 Subject: [PATCH 1/2] feat: distro/DE selector UI and real, verified proot invocation flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires PR #25's backend into an actual on-device build flow (menu entry -> distro pick -> DE pick -> confirm code -> build -> back to container list), plus a significant correctness fix to the proot invocation itself found while building this. ## UI (new) - DistroSelectFragment / DeSelectFragment (+ DistroBuildViewModel, activity-scoped like ContainerManageViewModel): RecyclerView pickers following this repo's existing ListAdapter+DiffUtil+ViewBinding pattern (mirrors QuicksFragment/ContainerManageFragment). DE tier 2 entries and any DE unavailable for the picked distro's package manager are visibly disabled, not hidden. - Reuses two existing pieces as-is rather than duplicating them: ConfirmInstallDialogFragment for the container-code prompt (already decoupled via setFragmentResult, works fine from a different fragment) and ProgressDialogFragment for build progress. - Entry point: new "Build New Container" menu item next to the existing Import action in ContainerManageFragment's menu. - New Screen.DistroSelect / Screen.DeSelect(distroAlias) cases in MainViewModel/MainActivity, following the exact existing screen-routing pattern. Deliberately did NOT add fragment back-stack entries for these — the existing ContainerInstall/ContainerMain screens have no back-stack either (confirmed: this app's whole fragment_container navigation has none), so adding one only for the new screens would be inconsistent, not fixing anything. ## Real correctness fix found while wiring this up ProotExec.kt's chroot flags (from PR #25) were a plausible-sounding guess (-r/-w/-b, no --change-id, no proc/sys fakes) that turned out to be meaningfully wrong. Found tiny-computer/images' actual, live proot-distro.tiny.yaml (a real, presumably-working example this repo's own README points people to for the .tiny.yaml format) and its boot_command/export_command use a completely different, more complete flag set: --change-id=1000:1000 (not -r/-w), --kernel-release spoofing, --sysvipc, and — most importantly — bind mounts for /proc/loadavg, /proc/stat, /proc/uptime, /proc/version, /proc/vmstat, and two sysctl entries, all targeting placeholder files ($CONTAINER_DIR/proc/.loadavg etc.) that upstream's own export.sh creates by snapshotting a LIVE session via `tar --transform`. A freshly-pulled distro image has none of these, so DistroInstaller now creates them with plausible static content after extraction and before any first-boot step. ProotExec now has two entry points matching the real file's two real commands: runAsRootCommand (mirrors export_command, used for first-boot/DE-install) and bootCommandTemplate (mirrors boot_command, written into the synthesized .tiny.yaml — TinyYamlBuilder.kt, new). de_packages.yaml gained a session_command field per tier-1 DE (what to run inside the container once its boot_command's X11 socket is ready — verified shape, real dbus-launch/DE-session commands) — tier-2 entries deliberately have none, since how a Wayland-only compositor gets a picture onto this app's Termux:X11 bridge is still the unprototyped question flagged in PR #25, and TinyYamlBuilder refuses to build a container for a DE with no session_command rather than guess one. ## Still not done / still flagged - Not device-tested — this repo still can't build without jniLibs.zip from upstream's releases page. - Tier-2 DE build path exists in DeInstaller (PR #25) but the UI here disables any DE lacking a session_command, so tier-2 is currently unselectable end-to-end until that's prototyped — matches the task brief's "don't block tier 1 on tier 2." - No fragment back-stack / no way to go back a step in the wizard besides the system back button (which exits, matching existing screens) — a real gap, just not a regression. Co-Authored-By: Claude Sonnet 5 --- app/src/main/assets/de_packages.yaml | 7 + .../java/com/fct/tc4/ui/main/MainActivity.kt | 12 + .../java/com/fct/tc4/ui/main/MainViewModel.kt | 4 + .../java/com/fct/tc4/ui/misc/DeInstaller.kt | 2 +- .../java/com/fct/tc4/ui/misc/DeRegistry.kt | 12 +- .../com/fct/tc4/ui/misc/DistroInstaller.kt | 39 ++- .../java/com/fct/tc4/ui/misc/ProotExec.kt | 113 ++++++-- .../com/fct/tc4/ui/misc/TinyYamlBuilder.kt | 77 ++++++ .../com/fct/tc4/ui/page/DeSelectFragment.kt | 249 ++++++++++++++++++ .../fct/tc4/ui/page/DistroBuildViewModel.kt | 134 ++++++++++ .../fct/tc4/ui/page/DistroSelectFragment.kt | 103 ++++++++ .../res/layout/tc4_fragment_de_select.xml | 21 ++ .../res/layout/tc4_fragment_distro_select.xml | 21 ++ app/src/main/res/layout/tc4_item_de.xml | 59 +++++ .../res/layout/tc4_item_de_section_header.xml | 12 + app/src/main/res/layout/tc4_item_distro.xml | 48 ++++ .../menu/tc4_fragment_container_manage.xml | 7 + app/src/main/res/values/strings.xml | 11 + 18 files changed, 906 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/fct/tc4/ui/misc/TinyYamlBuilder.kt create mode 100644 app/src/main/java/com/fct/tc4/ui/page/DeSelectFragment.kt create mode 100644 app/src/main/java/com/fct/tc4/ui/page/DistroBuildViewModel.kt create mode 100644 app/src/main/java/com/fct/tc4/ui/page/DistroSelectFragment.kt create mode 100644 app/src/main/res/layout/tc4_fragment_de_select.xml create mode 100644 app/src/main/res/layout/tc4_fragment_distro_select.xml create mode 100644 app/src/main/res/layout/tc4_item_de.xml create mode 100644 app/src/main/res/layout/tc4_item_de_section_header.xml create mode 100644 app/src/main/res/layout/tc4_item_distro.xml diff --git a/app/src/main/assets/de_packages.yaml b/app/src/main/assets/de_packages.yaml index 2cee0aa..0808f39 100644 --- a/app/src/main/assets/de_packages.yaml +++ b/app/src/main/assets/de_packages.yaml @@ -40,6 +40,10 @@ des: - alias: xfce4 display_name: "XFCE4" tier: 1 + # Run inside the container's already-logged-in boot_command session + # (see ProotExec.bootCommandTemplate()) once the X11 socket is ready — + # NOT a separate proot invocation. + session_command: "dbus-launch --exit-with-session startxfce4" package_managers: pacman: ["xfce4", "xfce4-goodies", "tigervnc", "dbus"] dnf: ["@xfce-desktop-environment", "tigervnc-server"] @@ -48,6 +52,7 @@ des: - alias: lxqt display_name: "LXQt" tier: 1 + session_command: "dbus-launch --exit-with-session startlxqt" package_managers: pacman: ["lxqt", "tigervnc", "dbus"] dnf: ["@lxqt-desktop-environment", "tigervnc-server"] @@ -56,6 +61,7 @@ des: - alias: mate display_name: "MATE" tier: 1 + session_command: "dbus-launch --exit-with-session mate-session" package_managers: pacman: ["mate", "tigervnc", "dbus"] dnf: ["@mate-desktop-environment", "tigervnc-server"] @@ -64,6 +70,7 @@ des: - alias: i3wm display_name: "i3wm" tier: 1 + session_command: "dbus-launch --exit-with-session i3" package_managers: pacman: ["i3-wm", "i3status", "dmenu", "tigervnc", "dbus"] dnf: ["@i3-desktop-environment", "tigervnc-server"] diff --git a/app/src/main/java/com/fct/tc4/ui/main/MainActivity.kt b/app/src/main/java/com/fct/tc4/ui/main/MainActivity.kt index fdbb4bc..a659c02 100644 --- a/app/src/main/java/com/fct/tc4/ui/main/MainActivity.kt +++ b/app/src/main/java/com/fct/tc4/ui/main/MainActivity.kt @@ -165,6 +165,10 @@ class MainActivity : AppCompatActivity() { fragment?.onImportAction() true } + R.id.containerBuildNew -> { + viewModel.navigateTo(MainViewModel.Screen.DistroSelect) + true + } R.id.enterGui -> { val fragment = supportFragmentManager.findFragmentByTag("ContainerMain") as? ContainerMainFragment fragment?.onEnterGui() @@ -215,6 +219,14 @@ class MainActivity : AppCompatActivity() { } tag = "ContainerMain" } + is MainViewModel.Screen.DistroSelect -> { + fragment = DistroSelectFragment() + tag = "DistroSelect" + } + is MainViewModel.Screen.DeSelect -> { + fragment = DeSelectFragment.newInstance(screen.distroAlias) + tag = "DeSelect" + } } if (supportFragmentManager.findFragmentByTag(tag) != null) return supportFragmentManager.commit { diff --git a/app/src/main/java/com/fct/tc4/ui/main/MainViewModel.kt b/app/src/main/java/com/fct/tc4/ui/main/MainViewModel.kt index 3c995f7..de326b7 100644 --- a/app/src/main/java/com/fct/tc4/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/fct/tc4/ui/main/MainViewModel.kt @@ -40,6 +40,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { data object ContainerManage : Screen() data class ContainerInstall(val code: String, val webpage: String?) : Screen() data class ContainerMain(val code: String) : Screen() + /** First step of the on-device distro/DE build flow — pick a base distro. */ + data object DistroSelect : Screen() + /** Second step — pick a desktop environment for the [distroAlias] chosen in DistroSelect. */ + data class DeSelect(val distroAlias: String) : Screen() } diff --git a/app/src/main/java/com/fct/tc4/ui/misc/DeInstaller.kt b/app/src/main/java/com/fct/tc4/ui/misc/DeInstaller.kt index 8b0ece8..161360d 100644 --- a/app/src/main/java/com/fct/tc4/ui/misc/DeInstaller.kt +++ b/app/src/main/java/com/fct/tc4/ui/misc/DeInstaller.kt @@ -73,7 +73,7 @@ class DeInstaller(private val execShell: suspend (block: () -> Unit) -> Int) { execShell { Global.setupEnvironment() Global.sendCommand( - ProotExec.runInContainerCommand(containerDir, installCommand(family, packages)), + ProotExec.runAsRootCommand(containerDir, installCommand(family, packages)), ) Global.sendCommand("exit") } diff --git a/app/src/main/java/com/fct/tc4/ui/misc/DeRegistry.kt b/app/src/main/java/com/fct/tc4/ui/misc/DeRegistry.kt index b9e182a..fd3b4cb 100644 --- a/app/src/main/java/com/fct/tc4/ui/misc/DeRegistry.kt +++ b/app/src/main/java/com/fct/tc4/ui/misc/DeRegistry.kt @@ -30,6 +30,15 @@ data class DeEntry( val requiresBridge: Boolean, /** package_manager family name -> list of packages/groups to install. */ val packageManagers: Map>, + /** + * Command run inside the container's already-logged-in boot_command + * session once the X11 socket is ready (see TinyYamlBuilder.kt) — null + * for every current tier-2 entry, since how a Wayland-only compositor + * actually gets a picture onto Termux:X11's X-server-shaped bridge is + * exactly the unprototyped question the task brief asked to flag rather + * than guess at. + */ + val sessionCommand: String?, ) { /** Null if this DE has no known install command for [family] yet (see de_packages.yaml). */ fun packagesFor(family: PackageManagerFamily): List? = packageManagers[family] @@ -83,7 +92,8 @@ object DeRegistry { val packageManagers = packageManagersRaw.entries.associate { (key, value) -> PackageManagerFamily.fromString(key) to value } + val sessionCommand = raw["session_command"] as? String - return DeEntry(alias, displayName, tier, requiresBridge, packageManagers) + return DeEntry(alias, displayName, tier, requiresBridge, packageManagers, sessionCommand) } } diff --git a/app/src/main/java/com/fct/tc4/ui/misc/DistroInstaller.kt b/app/src/main/java/com/fct/tc4/ui/misc/DistroInstaller.kt index 2cc1edc..0180ce0 100644 --- a/app/src/main/java/com/fct/tc4/ui/misc/DistroInstaller.kt +++ b/app/src/main/java/com/fct/tc4/ui/misc/DistroInstaller.kt @@ -122,20 +122,55 @@ class DistroInstaller( if (dir.exists()) dir.deleteRecursively() dir.mkdirs() + // Session 1: extraction only. Must fully finish (real distro images + // already ship empty proc/sys mountpoint dirs) before the + // placeholder files below can be safely written into them. execShell { Global.setupEnvironment() Global.sendCommand(extractionCommand(code, cacheFile.absolutePath)) + Global.sendCommand("rm -f ${cacheFile.absolutePath}") + Global.sendCommand("exit") + } + + createProcPlaceholders(dir) + + // Session 2: first-boot steps, now that the placeholder files + // ProotExec.runAsRootCommand's bind mounts target actually exist. + execShell { + Global.setupEnvironment() distro.firstBootSteps.forEachIndexed { index, step -> onProgress(Progress.RunningFirstBootStep(distro, index, distro.firstBootSteps.size, step)) - Global.sendCommand(ProotExec.runInContainerCommand(dir.absolutePath, step)) + Global.sendCommand(ProotExec.runAsRootCommand(dir.absolutePath, step)) } - Global.sendCommand("rm -f ${cacheFile.absolutePath}") Global.sendCommand("exit") } onProgress(Progress.Completed(distro, code)) } + /** + * Writes the seven proc/sys placeholder files ProotExec's bind mounts + * target (/proc/loadavg, /proc/stat, etc.) — for every OTHER container + * in this app these are real snapshots captured from a live proot + * session by upstream's export.sh; a freshly-pulled distro image has + * none of them, so this writes plausible static content instead. Must + * run after extraction and before any first-boot step (they all go + * through ProotExec.runAsRootCommand, which assumes these exist). + */ + private fun createProcPlaceholders(containerDir: File) { + File(containerDir, "sys").mkdirs() + File(containerDir, "proc").mkdirs() + ProotExec.procPlaceholders.forEach { (relativePath, content) -> + val file = File(containerDir, relativePath) + if (relativePath.endsWith(".empty")) { + file.mkdirs() + } else { + file.parentFile?.mkdirs() + file.writeText(content) + } + } + } + /** Same tar+proot extraction flags ContainerManageViewModel already uses for rootfs.tar.zst. */ private fun extractionCommand(alias: String): String = extractionCommand(alias, "\$CACHE_DIR/distro-rootfs-$alias.tar.gz") diff --git a/app/src/main/java/com/fct/tc4/ui/misc/ProotExec.kt b/app/src/main/java/com/fct/tc4/ui/misc/ProotExec.kt index f107101..ef08257 100644 --- a/app/src/main/java/com/fct/tc4/ui/misc/ProotExec.kt +++ b/app/src/main/java/com/fct/tc4/ui/misc/ProotExec.kt @@ -18,33 +18,104 @@ package com.fct.tc4.ui.misc /** - * Builds a shell command that chroots into a container's rootfs via proot - * and runs a command as root inside it — used by both DistroInstaller - * (first-boot setup) and DeInstaller (desktop-environment package install). + * Builds shell commands that enter a container's rootfs via proot — used by + * DistroInstaller (first-boot setup), DeInstaller (DE package install), and + * for synthesizing a fresh container's `boot_command` (TinyYamlBuilder.kt). * - * NOT YET VALIDATED ON A REAL DEVICE. This mirrors the flag conventions - * ContainerManageViewModel.performInstall() already uses for extraction - * (--link2symlink, $BIN_DIR/proot, the PROOT_LOADER*/PROOT_TMP_DIR env vars - * from Global.setupEnvironment()) plus the standard proot chroot flags - * (-r rootfs, -w workdir, -b bind-mounts for /dev, /proc, /sys — needed by - * most package managers). This app's actual per-container `boot_command` - * (the real, working proot invocation for LAUNCHING a container) lives - * inside each container's own .tiny.yaml, authored per upstream image and - * never checked into this repo, so there was no concrete example to copy - * exactly for this kind of one-time, non-interactive invocation. This is - * the single most significant open risk across the distro/DE selector - * work — flagging per the task brief's "surface real proot/Android 16 - * sandboxing limits early" requirement rather than presenting it as - * verified. Needs a real-device smoke test against each of the four - * distros before this can be considered done. + * These flags are NOT a guess — they're copied from the real, live + * `boot_command`/`export_command` in tiny-computer/images's + * proot-distro.tiny.yaml (an actual upstream-authored, presumably-working + * config for a real proot-distro-installed container), which is the + * concrete example this repo itself doesn't check in anywhere (each + * container's `boot_command` lives in its own imported .tiny.yaml, not in + * source). [runAsRootCommand] mirrors that file's `export_command` (which + * enters as root at /root — the shape first-boot/package-install commands + * need); the DISPLAY=:6 / LANG=en_US.UTF-8 defaults used elsewhere in this + * package for synthesized configs also come from that same file. + * + * One real gap this does NOT paper over: `export_command`'s bind mounts for + * /proc/loadavg, /proc/stat, /proc/uptime, /proc/version, /proc/vmstat, and + * two sysctl entries all target placeholder files + * ($CONTAINER_DIR/proc/.loadavg etc.) that upstream's own export.sh creates + * by literally snapshotting those paths out of a REAL, already-booted proot + * session via `tar --transform`. A freshly-pulled Debian/Arch/Fedora/Ubuntu + * image has none of these — DistroInstaller.kt creates them with plausible + * static placeholder content (see createProcPlaceholders()) since there is + * no live session to snapshot them from. This is a real, if probably minor, + * behavioral difference from how every other container in this app was + * actually built, and hasn't been device-tested. */ object ProotExec { - fun runInContainerCommand(containerDir: String, command: String): String { + + /** The seven proc/sys placeholder files export.sh normally snapshots from a live session. */ + val procPlaceholders: Map = mapOf( + "sys/.empty" to "", // bind-mounted as a directory target for /sys/fs/selinux + "proc/.loadavg" to "0.00 0.00 0.00 1/1 1\n", + "proc/.stat" to "cpu 0 0 0 0 0 0 0 0 0 0\nbtime 0\nprocesses 1\n", + "proc/.uptime" to "0.00 0.00\n", + "proc/.version" to + "Linux version 6.17.0-PRoot-Distro (proot@localhost) #1 SMP PREEMPT_DYNAMIC " + + "Fri, 10 Oct 2025 00:00:00 +0000\n", + "proc/.vmstat" to "nr_free_pages 0\n", + "proc/.sysctl_entry_cap_last_cap" to "40\n", + "proc/.sysctl_inotify_max_user_watches" to "524288\n", + ) + + /** + * The shared bind-mount/env flag block from the real boot_command / + * export_command, parameterized only by $CONTAINER_DIR and $CACHE_DIR + * (both already exported by Global.setupEnvironment()). + */ + private val commonProotFlags = $$"""--kill-on-exit --link2symlink --sysvipc \ + --kernel-release="Linux localhost 6.17.0-PRoot-Distro #1 SMP PREEMPT_DYNAMIC Fri, 10 Oct 2025 00:00:00 +0000 aarch64 localdomain -1" \ + -L \ + --bind=/data --bind=/dev --bind=/proc --bind=/sys --bind=/dev/urandom:/dev/random \ + --bind=/proc/self/fd:/dev/fd --bind=/proc/self/fd/0:/dev/stdin --bind=/proc/self/fd/1:/dev/stdout --bind=/proc/self/fd/2:/dev/stderr \ + --bind=$CONTAINER_DIR/sys/.empty:/sys/fs/selinux \ + --bind=$CONTAINER_DIR/proc/.loadavg:/proc/loadavg \ + --bind=$CONTAINER_DIR/proc/.stat:/proc/stat \ + --bind=$CONTAINER_DIR/proc/.uptime:/proc/uptime \ + --bind=$CONTAINER_DIR/proc/.version:/proc/version \ + --bind=$CONTAINER_DIR/proc/.vmstat:/proc/vmstat \ + --bind=$CONTAINER_DIR/proc/.sysctl_entry_cap_last_cap:/proc/sys/kernel/cap_last_cap \ + --bind=$CONTAINER_DIR/proc/.sysctl_inotify_max_user_watches:/proc/sys/fs/inotify/max_user_watches \ + --bind=$CACHE_DIR/tmp:/dev/shm --bind=$CACHE_DIR/tmp:/tmp --bind=$CACHE_DIR/run:/run""" + .trimIndent() + + /** + * Enters [containerDir] as root at /root and runs [command] — mirrors + * the real export_command shape. Used for one-time, non-interactive + * setup (first-boot steps, DE package installs), not for launching an + * interactive session (that's [bootCommandTemplate]). + */ + fun runAsRootCommand(containerDir: String, command: String): String { val escapedCommand = command.replace("'", "'\\''") return $$""" - $BIN_DIR/proot --link2symlink -r $$containerDir -w /root -b /dev -b /proc -b /sys \ - /usr/bin/env -i HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + export CONTAINER_DIR=$$containerDir + $BIN_DIR/proot -H $$commonProotFlags --rootfs=$CONTAINER_DIR --cwd=/root \ + /usr/bin/env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + LANG=en_US.UTF-8 HOME=/root USER=root TERM=xterm-256color \ /bin/sh -c '$$escapedCommand' """.trimIndent() } + + /** + * The `boot_command` string written into a freshly-built container's + * .tiny.yaml (TinyYamlBuilder.kt) — logs in as the "tiny" user + * interactively, matching every other container's real boot_command + * shape (this exact template, minus the -H flag and swapping cwd/user, + * is copied from tiny-computer/images's real proot-distro.tiny.yaml). + */ + fun bootCommandTemplate(): String = $$""" + $BIN_DIR/proot $EXTRA_ARGS -H $$commonProotFlags --rootfs=$CONTAINER_DIR --cwd=/home/tiny \ + --change-id=1000:1000 \ + --mount=$PUBLIC_DIR:/home/tiny/public \ + --mount=/storage/self/primary:/mnt/sdcard \ + /usr/bin/env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$EXTRA_PATH \ + LD_LIBRARY_PATH=$EXTRA_LD_LIBRARY_PATH LD_PRELOAD=$EXTRA_LD_PRELOAD \ + DISPLAY=:6 LANG=en_US.UTF-8 \ + MOZ_FAKE_NO_SANDBOX=1 QTWEBENGINE_DISABLE_SANDBOX=1 ELECTRON_DISABLE_SANDBOX=1 \ + HOME=/home/tiny USER=tiny $EXTRA_ENV \ + TERM=xterm-256color COLORTERM=truecolor /bin/bash -l + """.trimIndent() } diff --git a/app/src/main/java/com/fct/tc4/ui/misc/TinyYamlBuilder.kt b/app/src/main/java/com/fct/tc4/ui/misc/TinyYamlBuilder.kt new file mode 100644 index 0000000..eab9769 --- /dev/null +++ b/app/src/main/java/com/fct/tc4/ui/misc/TinyYamlBuilder.kt @@ -0,0 +1,77 @@ +// TinyYamlBuilder.kt -- This file is part of tiny_container. +// +// Copyright (C) 2026 Caten Hu +// +// Tiny Container is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or any later version. +// +// Tiny Container is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. + +package com.fct.tc4.ui.misc + +/** + * Synthesizes the config map a freshly on-device-built (distro + DE) + * container needs — every other container's config comes from an + * upstream-authored .tiny.yaml imported wholesale; this is the one built by + * this app itself rather than imported. + */ +object TinyYamlBuilder { + + /** Matches the DISPLAY=:6 baked into ProotExec.bootCommandTemplate(). */ + private const val X11_DISPLAY = "6" + + /** + * @throws IllegalStateException if [de] is non-null but has no + * [DeEntry.sessionCommand] (every current tier-2 entry) — there is + * nothing correct to write into `feature[].command` in that case, so + * this refuses rather than shipping a container with a guessed, + * probably-broken session launch command. Callers should check + * `de.sessionCommand != null` before offering a tier-2 DE as buildable. + */ + fun build(code: String, distro: DistroEntry, de: DeEntry?): Map { + val sessionCommand = de?.let { + it.sessionCommand + ?: throw IllegalStateException( + "DE '${it.alias}' has no session_command yet — its X11/Wayland launch " + + "path isn't validated, so there's nothing correct to write here.", + ) + } + + val name = "${distro.displayName}${de?.let { " (${it.displayName})" } ?: ""}" + val description = buildString { + append("Built on-device from ${distro.displayName}") + if (de != null) append(" with ${de.displayName}") + append(".") + } + + val config = mutableMapOf( + "code" to code, + "name" to name, + "description" to description, + "boot_command" to ProotExec.bootCommandTemplate(), + ) + + if (sessionCommand != null) { + config["feature"] = listOf( + mapOf( + "type" to "x11", + "name" to "X11", + "description" to "Termux:X11 graphical session.", + "enabled" to true, + "args" to listOf(":$X11_DISPLAY", "-extension", "MIT-SHM"), + "command" to sessionCommand, + ), + ) + } + + return config + } +} diff --git a/app/src/main/java/com/fct/tc4/ui/page/DeSelectFragment.kt b/app/src/main/java/com/fct/tc4/ui/page/DeSelectFragment.kt new file mode 100644 index 0000000..1a2722c --- /dev/null +++ b/app/src/main/java/com/fct/tc4/ui/page/DeSelectFragment.kt @@ -0,0 +1,249 @@ +// DeSelectFragment.kt -- This file is part of tiny_container. +// +// Copyright (C) 2026 Caten Hu +// +// Tiny Container is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or any later version. +// +// Tiny Container is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. + +package com.fct.tc4.ui.page + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.fct.tc4.R +import com.fct.tc4.databinding.Tc4FragmentDeSelectBinding +import com.fct.tc4.databinding.Tc4ItemDeBinding +import com.fct.tc4.databinding.Tc4ItemDeSectionHeaderBinding +import com.fct.tc4.ui.main.MainViewModel +import com.fct.tc4.ui.misc.ConfirmInstallDialogFragment +import com.fct.tc4.ui.misc.DeEntry +import com.fct.tc4.ui.misc.DeTier +import com.fct.tc4.ui.misc.DistroEntry +import com.fct.tc4.ui.misc.ProgressDialogFragment +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch + +/** + * Stage 2 of the on-device build flow — pick a DE (or none, for a + * command-line-only container) for the distro chosen in DistroSelectFragment, + * confirm a container code, then run the build via [DistroBuildViewModel]. + */ +class DeSelectFragment : Fragment() { + + private var _binding: Tc4FragmentDeSelectBinding? = null + private val binding get() = _binding!! + + private val mainViewModel: MainViewModel by activityViewModels() + private val buildViewModel: DistroBuildViewModel by activityViewModels() + + private val distroAlias: String by lazy { requireArguments().getString(ARG_DISTRO_ALIAS)!! } + private val distro: DistroEntry by lazy { + buildViewModel.distroByAlias(distroAlias) + ?: error("Unknown distro alias reached DeSelectFragment: $distroAlias") + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.tc4_de_select_title) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + _binding = Tc4FragmentDeSelectBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val adapter = DeAdapter( + isSelectable = { de -> buildViewModel.isSelectable(de, distro) }, + onClick = { de -> onDeChosen(de) }, + ) + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = adapter + + val items = buildList { + add(DeListItem.SkipOption) + add(DeListItem.Header(getString(R.string.tc4_de_tier1_header))) + buildViewModel.tier1Des.forEach { add(DeListItem.De(it)) } + if (buildViewModel.tier2Des.isNotEmpty()) { + add(DeListItem.Header(getString(R.string.tc4_de_tier2_header))) + buildViewModel.tier2Des.forEach { add(DeListItem.De(it)) } + } + } + adapter.submitList(items) + + childFragmentManager.setFragmentResultListener(REQUEST_CONFIRM_CODE, viewLifecycleOwner) { _, bundle -> + if (!bundle.getBoolean(ConfirmInstallDialogFragment.KEY_CONFIRMED)) return@setFragmentResultListener + val code = bundle.getString(ConfirmInstallDialogFragment.KEY_CODE) ?: return@setFragmentResultListener + val de = pendingDe + startBuildProgressObserver() + buildViewModel.startBuild(distro, de, code) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + buildViewModel.buildState.collect { state -> + when (state) { + is DistroBuildViewModel.BuildState.Idle -> Unit + is DistroBuildViewModel.BuildState.Progress -> { + getOrShowProgressDialog()?.updateTitle(state.message) + } + is DistroBuildViewModel.BuildState.Completed -> { + (childFragmentManager.findFragmentByTag(PROGRESS_TAG) as? ProgressDialogFragment)?.dismiss() + buildViewModel.resetBuildState() + mainViewModel.navigateTo(MainViewModel.Screen.ContainerManage) + } + is DistroBuildViewModel.BuildState.Failed -> { + (childFragmentManager.findFragmentByTag(PROGRESS_TAG) as? ProgressDialogFragment)?.dismiss() + buildViewModel.resetBuildState() + Snackbar.make(binding.root, getString(R.string.tc4_build_failed, state.message), Snackbar.LENGTH_LONG).show() + } + } + } + } + } + } + + private var pendingDe: DeEntry? = null + + private fun onDeChosen(de: DeEntry?) { + if (de != null && !buildViewModel.isSelectable(de, distro)) return + pendingDe = de + ConfirmInstallDialogFragment.show( + childFragmentManager, + REQUEST_CONFIRM_CODE, + initialCode = distro.alias, + name = de?.let { "${distro.displayName} (${it.displayName})" } ?: distro.displayName, + description = "", + ) + } + + private fun getOrShowProgressDialog(): ProgressDialogFragment? { + (childFragmentManager.findFragmentByTag(PROGRESS_TAG) as? ProgressDialogFragment)?.let { return it } + return ProgressDialogFragment.newBuilder(childFragmentManager).show(PROGRESS_TAG) + } + + private fun startBuildProgressObserver() { + getOrShowProgressDialog() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val ARG_DISTRO_ALIAS = "distro_alias" + private const val REQUEST_CONFIRM_CODE = "de_select_confirm_code" + private const val PROGRESS_TAG = "de_select_build_progress" + + fun newInstance(distroAlias: String): DeSelectFragment = DeSelectFragment().apply { + arguments = Bundle().apply { putString(ARG_DISTRO_ALIAS, distroAlias) } + } + } +} + +private sealed interface DeListItem { + data class Header(val title: String) : DeListItem + data object SkipOption : DeListItem + data class De(val entry: DeEntry) : DeListItem +} + +private class DeAdapter( + private val isSelectable: (DeEntry) -> Boolean, + private val onClick: (DeEntry?) -> Unit, +) : ListAdapter(DIFF_CALLBACK) { + + companion object { + private const val TYPE_HEADER = 0 + private const val TYPE_SKIP = 1 + private const val TYPE_DE = 2 + + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: DeListItem, new: DeListItem) = when { + old is DeListItem.Header && new is DeListItem.Header -> old.title == new.title + old is DeListItem.SkipOption && new is DeListItem.SkipOption -> true + old is DeListItem.De && new is DeListItem.De -> old.entry.alias == new.entry.alias + else -> false + } + override fun areContentsTheSame(old: DeListItem, new: DeListItem) = old == new + } + } + + override fun getItemViewType(position: Int): Int = when (getItem(position)) { + is DeListItem.Header -> TYPE_HEADER + is DeListItem.SkipOption -> TYPE_SKIP + is DeListItem.De -> TYPE_DE + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + TYPE_HEADER -> HeaderVH(Tc4ItemDeSectionHeaderBinding.inflate(inflater, parent, false)) + TYPE_SKIP, TYPE_DE -> DeVH(Tc4ItemDeBinding.inflate(inflater, parent, false), isSelectable, onClick) + else -> error("unknown view type: $viewType") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = getItem(position)) { + is DeListItem.Header -> (holder as HeaderVH).bind(item) + is DeListItem.SkipOption -> (holder as DeVH).bindSkip() + is DeListItem.De -> (holder as DeVH).bind(item.entry) + } + } + + class HeaderVH(private val binding: Tc4ItemDeSectionHeaderBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(header: DeListItem.Header) { + binding.headerText.text = header.title + } + } + + class DeVH( + private val binding: Tc4ItemDeBinding, + private val isSelectable: (DeEntry) -> Boolean, + private val onClick: (DeEntry?) -> Unit, + ) : RecyclerView.ViewHolder(binding.root) { + fun bindSkip() { + binding.name.text = binding.root.context.getString(R.string.tc4_de_skip) + binding.status.visibility = View.GONE + binding.tierBadge.visibility = View.GONE + binding.itemCard.setOnClickListener { onClick(null) } + } + + fun bind(de: DeEntry) { + binding.name.text = de.displayName + binding.tierBadge.visibility = + if (de.tier == DeTier.EXPERIMENTAL) View.VISIBLE else View.GONE + val available = isSelectable(de) + binding.status.visibility = if (available) View.GONE else View.VISIBLE + binding.status.text = binding.root.context.getString(R.string.tc4_de_status_unavailable) + binding.itemCard.isEnabled = available + binding.itemCard.alpha = if (available) 1f else 0.5f + binding.itemCard.setOnClickListener { if (available) onClick(de) } + } + } +} diff --git a/app/src/main/java/com/fct/tc4/ui/page/DistroBuildViewModel.kt b/app/src/main/java/com/fct/tc4/ui/page/DistroBuildViewModel.kt new file mode 100644 index 0000000..6e092c6 --- /dev/null +++ b/app/src/main/java/com/fct/tc4/ui/page/DistroBuildViewModel.kt @@ -0,0 +1,134 @@ +// DistroBuildViewModel.kt -- This file is part of tiny_container. +// +// Copyright (C) 2026 Caten Hu +// +// Tiny Container is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or any later version. +// +// Tiny Container is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. + +package com.fct.tc4.ui.page + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.fct.tc4.ui.misc.ConfigManager +import com.fct.tc4.ui.misc.DeEntry +import com.fct.tc4.ui.misc.DeInstaller +import com.fct.tc4.ui.misc.DeRegistry +import com.fct.tc4.ui.misc.DeTier +import com.fct.tc4.ui.misc.DistroEntry +import com.fct.tc4.ui.misc.DistroInstaller +import com.fct.tc4.ui.misc.DistroRegistry +import com.fct.tc4.ui.misc.Global +import com.fct.tc4.ui.misc.TinyYamlBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +/** + * Shared, activity-scoped state for the on-device distro+DE build flow — + * DistroSelectFragment and DeSelectFragment both read/write this via + * activityViewModels(), the same pattern ContainerManageViewModel uses. + */ +class DistroBuildViewModel(application: Application) : AndroidViewModel(application) { + + sealed interface BuildState { + data object Idle : BuildState + data class Progress(val message: String) : BuildState + data class Completed(val code: String) : BuildState + data class Failed(val message: String) : BuildState + } + + private val _buildState = MutableStateFlow(BuildState.Idle) + val buildState: StateFlow = _buildState.asStateFlow() + + val distros: List by lazy { DistroRegistry.load(getApplication()) } + val tier1Des: List by lazy { DeRegistry.byTier(getApplication(), DeTier.STABLE) } + val tier2Des: List by lazy { DeRegistry.byTier(getApplication(), DeTier.EXPERIMENTAL) } + + fun distroByAlias(alias: String): DistroEntry? = + distros.firstOrNull { it.alias == alias } + + /** Whether [de] can actually be selected for [distro] right now (not just listed). */ + fun isSelectable(de: DeEntry, distro: DistroEntry): Boolean = + de.sessionCommand != null && de.packagesFor(distro.packageManager) != null + + fun resetBuildState() { + _buildState.value = BuildState.Idle + } + + /** + * Runs the full build: distro rootfs fetch/extract/first-boot, then (if + * [de] is non-null) DE package install, then writes the synthesized + * .tiny.yaml and registers the container. Mirrors + * ContainerManageViewModel.performInstall()'s shape, reusing the same + * "run in a fresh terminal session, suspend until it exits" execShell + * pattern. + */ + fun startBuild(distro: DistroEntry, de: DeEntry?, code: String) { + viewModelScope.launch { + try { + val distroInstaller = DistroInstaller(getApplication(), ::execShell) + _buildState.value = BuildState.Progress("Resolving ${distro.displayName}…") + distroInstaller.install(distro, code) { progress -> + _buildState.value = BuildState.Progress(describeDistroProgress(progress)) + } + + if (de != null) { + val containerDir = getApplication().dataDir.resolve(code) + _buildState.value = BuildState.Progress("Installing ${de.displayName}…") + val deInstaller = DeInstaller(::execShell) + val result = deInstaller.install(de, distro, containerDir.absolutePath) + if (result is DeInstaller.Result.Refused) { + _buildState.value = BuildState.Failed(result.reason) + return@launch + } + } + + val config = TinyYamlBuilder.build(code, distro, de) + Global.installedContainers += code + ConfigManager.save(code, config) + + _buildState.value = BuildState.Completed(code) + } catch (e: Exception) { + _buildState.value = BuildState.Failed(e.message ?: e.toString()) + } + } + } + + private fun describeDistroProgress(progress: DistroInstaller.Progress): String = when (progress) { + is DistroInstaller.Progress.Resolving -> "Resolving ${progress.distro.displayName}…" + is DistroInstaller.Progress.Downloading -> { + val pct = if (progress.totalBytes > 0) { + " (${(progress.bytesDownloaded * 100 / progress.totalBytes)}%)" + } else "" + "Downloading ${progress.distro.displayName}$pct" + } + is DistroInstaller.Progress.Extracting -> "Extracting ${progress.distro.displayName}…" + is DistroInstaller.Progress.RunningFirstBootStep -> + "First-boot setup (${progress.stepIndex + 1}/${progress.totalSteps})…" + is DistroInstaller.Progress.Completed -> "Done." + } + + /** Same "run in a fresh terminal session and suspend until it exits" helper as ContainerManageViewModel. */ + private suspend fun execShell(block: () -> Unit): Int = withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + Global.newSession(onFinished = { exitCode -> cont.resume(exitCode) }) + block() + } + } +} diff --git a/app/src/main/java/com/fct/tc4/ui/page/DistroSelectFragment.kt b/app/src/main/java/com/fct/tc4/ui/page/DistroSelectFragment.kt new file mode 100644 index 0000000..df388ee --- /dev/null +++ b/app/src/main/java/com/fct/tc4/ui/page/DistroSelectFragment.kt @@ -0,0 +1,103 @@ +// DistroSelectFragment.kt -- This file is part of tiny_container. +// +// Copyright (C) 2026 Caten Hu +// +// Tiny Container is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or any later version. +// +// Tiny Container is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty +// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. + +package com.fct.tc4.ui.page + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.fct.tc4.R +import com.fct.tc4.databinding.Tc4FragmentDistroSelectBinding +import com.fct.tc4.databinding.Tc4ItemDistroBinding +import com.fct.tc4.ui.main.MainViewModel +import com.fct.tc4.ui.misc.DistroEntry + +/** Stage 1 of the on-device build flow — pick a base distro. See DeSelectFragment for stage 2. */ +class DistroSelectFragment : Fragment() { + + private var _binding: Tc4FragmentDistroSelectBinding? = null + private val binding get() = _binding!! + + private val mainViewModel: MainViewModel by activityViewModels() + private val buildViewModel: DistroBuildViewModel by activityViewModels() + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.tc4_distro_select_title) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + _binding = Tc4FragmentDistroSelectBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val adapter = DistroAdapter { distro -> + mainViewModel.navigateTo(MainViewModel.Screen.DeSelect(distro.alias)) + } + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = adapter + adapter.submitList(buildViewModel.distros) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +private class DistroAdapter( + private val onClick: (DistroEntry) -> Unit, +) : ListAdapter(DIFF_CALLBACK) { + + companion object { + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: DistroEntry, new: DistroEntry) = old.alias == new.alias + override fun areContentsTheSame(old: DistroEntry, new: DistroEntry) = old == new + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = Tc4ItemDistroBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding, onClick) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + class ViewHolder( + private val binding: Tc4ItemDistroBinding, + private val onClick: (DistroEntry) -> Unit, + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(distro: DistroEntry) { + binding.name.text = distro.displayName + binding.packageManager.text = distro.packageManager.name.lowercase() + binding.itemCard.setOnClickListener { onClick(distro) } + } + } +} diff --git a/app/src/main/res/layout/tc4_fragment_de_select.xml b/app/src/main/res/layout/tc4_fragment_de_select.xml new file mode 100644 index 0000000..e9f6555 --- /dev/null +++ b/app/src/main/res/layout/tc4_fragment_de_select.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/src/main/res/layout/tc4_fragment_distro_select.xml b/app/src/main/res/layout/tc4_fragment_distro_select.xml new file mode 100644 index 0000000..85df154 --- /dev/null +++ b/app/src/main/res/layout/tc4_fragment_distro_select.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/src/main/res/layout/tc4_item_de.xml b/app/src/main/res/layout/tc4_item_de.xml new file mode 100644 index 0000000..905017f --- /dev/null +++ b/app/src/main/res/layout/tc4_item_de.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/tc4_item_de_section_header.xml b/app/src/main/res/layout/tc4_item_de_section_header.xml new file mode 100644 index 0000000..a314ab2 --- /dev/null +++ b/app/src/main/res/layout/tc4_item_de_section_header.xml @@ -0,0 +1,12 @@ + + diff --git a/app/src/main/res/layout/tc4_item_distro.xml b/app/src/main/res/layout/tc4_item_distro.xml new file mode 100644 index 0000000..f8bcfc8 --- /dev/null +++ b/app/src/main/res/layout/tc4_item_distro.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/tc4_fragment_container_manage.xml b/app/src/main/res/menu/tc4_fragment_container_manage.xml index 226846d..2ed3f2d 100644 --- a/app/src/main/res/menu/tc4_fragment_container_manage.xml +++ b/app/src/main/res/menu/tc4_fragment_container_manage.xml @@ -14,4 +14,11 @@ android:icon="@drawable/download_24dp_1f1f1f_fill0_wght400_grad0_opsz24" app:iconTint="?attr/colorControlNormal" app:showAsAction="ifRoom|withText" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f5259d..dbc97df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -295,6 +295,17 @@ Request File Access Disable Battery Optimization Shortcuts + Build New Container + + + Choose a Base Distro + Choose a Desktop Environment + Stable + Experimental (Wayland) + Experimental + Skip (command-line only) + Not available for this distro yet + Build failed: %1$s From 4680ce2cd2d14b41e90f814239bf8fccf04d5219 Mon Sep 17 00:00:00 2001 From: DaRipper Date: Mon, 20 Jul 2026 22:43:36 -0500 Subject: [PATCH 2/2] fix: missing imports for DistroSelectFragment/DeSelectFragment CI (#27) caught this: showFragmentForScreen()'s new Screen.DistroSelect/ DeSelect cases referenced these classes without importing them. Co-Authored-By: Claude Sonnet 5 --- app/src/main/java/com/fct/tc4/ui/main/MainActivity.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/fct/tc4/ui/main/MainActivity.kt b/app/src/main/java/com/fct/tc4/ui/main/MainActivity.kt index a659c02..cb19c9a 100644 --- a/app/src/main/java/com/fct/tc4/ui/main/MainActivity.kt +++ b/app/src/main/java/com/fct/tc4/ui/main/MainActivity.kt @@ -42,6 +42,8 @@ import com.fct.tc4.ui.misc.WipeLayout import com.fct.tc4.ui.page.ContainerInstallFragment import com.fct.tc4.ui.page.ContainerMainFragment import com.fct.tc4.ui.page.ContainerManageFragment +import com.fct.tc4.ui.page.DeSelectFragment +import com.fct.tc4.ui.page.DistroSelectFragment import com.fct.tc4.ui.misc.Global import com.fct.tc4.ui.page.TerminalFragment import com.fct.tc4.ui.page.ContainerManageViewModel