diff --git a/app/src/main/assets/de_packages.yaml b/app/src/main/assets/de_packages.yaml new file mode 100644 index 0000000..0808f39 --- /dev/null +++ b/app/src/main/assets/de_packages.yaml @@ -0,0 +1,119 @@ +# de_packages.yaml -- This file is part of tiny_container. +# +# Config-driven registry of desktop-environment entries offered by the +# on-device DE selector, tiered per the task brief (tier 1 = stable/X11, +# tier 2 = experimental/Wayland). Add a new DE by adding an entry here — no +# Kotlin changes required in the selector UI or installer to pick it up. +# +# Every tier-1 package_managers. value below was verified against a +# real package database as of 2026-07-20 (not guessed): +# - pacman: archlinux.org's package/group search JSON API and group pages +# (xfce4, xfce4-goodies, lxqt, mate, i3 are pacman GROUPS, not single +# packages — `pacman -S ` installs everything in the group). +# - dnf: Fedora's real comps group IDs, from +# https://pagure.io/fedora-comps/raw/main/f/comps-f42.xml.in +# (dnf group install "@"). +# - apt: sources.debian.org/api (package existence check) PLUS the +# xfce4 line is the literal command from the real, verified +# upstream tiny-computer/images README ("sudo apt install +# dbus-x11 xfce4 tigervnc-standalone-server ... -y"). +# +# Fields: +# alias: Short name, used as the feature-type discriminator +# the app writes into the container's boot_command/ +# feature config once a DE is chosen. +# display_name: Shown in the selector UI. +# tier: 1 (stable, X11-based) or 2 (experimental, Wayland). +# requires_bridge: Only meaningful for tier 2 — see DeInstaller.kt. +# True means this DE needs a Wayland-compositor bridge +# that hasn't been prototyped/validated yet (see +# docs/tier2-wayland-bridge-findings.md once written). +# package_managers: Map of pacman/dnf/apt -> list of package/group names +# to install for this DE on that package manager +# family. A distro's package_manager (distros.yaml) +# selects which of these lists applies. + +des: + # --------------------------------------------------------------------- + # Tier 1 — stable, X11, proven under proot + Termux:X11 + # --------------------------------------------------------------------- + - 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"] + apt: ["dbus-x11", "xfce4", "tigervnc-standalone-server"] + + - 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"] + apt: ["dbus-x11", "task-lxqt-desktop", "tigervnc-standalone-server"] + + - 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"] + apt: ["dbus-x11", "task-mate-desktop", "tigervnc-standalone-server"] + + - 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"] + apt: ["dbus-x11", "i3-wm", "tigervnc-standalone-server"] + + # --------------------------------------------------------------------- + # Tier 2 — experimental, Wayland. NOT validated to actually work end to + # end yet (see DeInstaller.kt / the task brief's own instruction to + # prototype against one distro before assuming this works). Package + # lists below are DELIBERATELY pacman-only (Arch), per the brief's own + # instruction to prototype tier 2 against one distro before expanding — + # checked here, not assumed: Fedora has NEITHER niri NOR cosmic-desktop + # in its official repos (packages.fedoraproject.org 404s for both, as of + # 2026-07-20), and there is no single "cosmic" package anywhere — COSMIC + # ships as ~25 separate packages on Arch (cosmic-session, cosmic-comp, + # cosmic-panel, etc., all in the `extra` repo). Debian/Ubuntu's apt + # situation for niri/cosmic-desktop was not verified deeply enough to + # trust yet (a source package existing on sources.debian.org is not the + # same as a real, current, arm64-buildable binary package — that's a + # meaningfully lower bar than what tier 1 above was actually checked + # against). Do not add dnf/apt entries here until that's done for real; + # see DeInstaller.kt's tier-2 install path, which refuses non-pacman + # tier-2 installs outright rather than guessing. + # --------------------------------------------------------------------- + - alias: niri + display_name: "niri (experimental)" + tier: 2 + requires_bridge: true + package_managers: + pacman: ["niri", "xwayland-satellite"] + + - alias: cosmic + display_name: "COSMIC (experimental)" + tier: 2 + requires_bridge: true + package_managers: + pacman: + - "cosmic-session" + - "cosmic-comp" + - "cosmic-panel" + - "cosmic-settings" + - "cosmic-settings-daemon" + - "cosmic-bg" + - "cosmic-launcher" + - "cosmic-files" + - "xdg-desktop-portal-cosmic" diff --git a/app/src/main/assets/distros.yaml b/app/src/main/assets/distros.yaml new file mode 100644 index 0000000..0f856ba --- /dev/null +++ b/app/src/main/assets/distros.yaml @@ -0,0 +1,133 @@ +# distros.yaml -- This file is part of tiny_container. +# +# Config-driven registry of base-distro entries offered by the on-device +# distro selector. Add a new distro by adding an entry here — no Kotlin +# changes required in the selector UI or installer to pick it up. +# +# Fields: +# alias: Short name. Used as the on-disk container "code" +# prefix and as the key into the per-distro/per-DE +# install-command manifest (de_packages.yaml). +# display_name: Shown in the selector UI. +# package_manager: One of: pacman, dnf, apt. Selects which install-command +# templates apply for this distro in de_packages.yaml. +# source: Where the base rootfs comes from. Two supported +# shapes (see OciRegistryClient.kt / DistroInstaller.kt): +# type: static_tarball -- a direct HTTPS/HTTP URL to +# a single gzip/xz tarball. +# type: oci -- pulled via the Docker +# Registry v2 HTTP API (registry + repository + tag). +# NOTE: this is a small, purpose-built client, not +# upstream proot-distro itself — proot-distro was +# rewritten in Python for its 2026 OCI-pull update +# and this app does not bundle a Python runtime. +# Verified live against registry-1.docker.io as of +# 2026-07-20: all three OCI-sourced distros below +# resolve to exactly one arm64 layer +# (application/vnd.oci.image.layer.v1.tar+gzip), +# so no layer-squashing/union-merge logic is needed +# — the single layer blob IS the rootfs tarball. +# first_boot_steps: Shell commands run once, inside the new container, +# immediately after extraction and before the first +# real login (keyring init, locale generation, user +# creation, etc.). Run in order, via the same terminal +# session used for the tar extraction step. + +distros: + - alias: archlinux + display_name: "Arch Linux" + package_manager: pacman + source: + type: static_tarball + # Redirects to a mirror; verified live 2026-07-20 (HTTP 302 -> 200). + url: "http://os.archlinuxarm.org/os/ArchLinuxARM-aarch64-latest.tar.gz" + first_boot_steps: + - "pacman-key --init" + - "pacman-key --populate archlinuxarm" + - "echo 'Server = http://mirror.archlinuxarm.org/$arch/$repo' > /etc/pacman.d/mirrorlist" + - "pacman -Sy --noconfirm sudo polkit" + - "sed -i 's/#en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen" + - "locale-gen" + - "echo 'LANG=en_US.UTF-8' > /etc/locale.conf" + - "ln -sf /usr/share/zoneinfo/UTC /etc/localtime" + - "useradd -m -s /bin/bash tiny" + - "echo 'tiny:tiny' | chpasswd" + - "echo 'root:tiny' | chpasswd" + - "echo '%wheel ALL=(ALL:ALL) NOPASSWD: ALL' > /etc/sudoers.d/wheel" + - "gpasswd -a tiny wheel" + - "chmod 0440 /etc/sudoers.d/wheel" + - "mkdir -p /etc/polkit-1/rules.d" + - "printf 'polkit.addRule(function(action, subject) {\\n if (subject.user == \"tiny\") {\\n return polkit.Result.YES;\\n }\\n});\\n' > /etc/polkit-1/rules.d/99-allow-all.rules" + + - alias: debian + display_name: "Debian" + package_manager: apt + source: + type: oci + registry: "registry-1.docker.io" + auth_realm: "https://auth.docker.io/token" + auth_service: "registry.docker.io" + repository: "library/debian" + tag: "bookworm" + first_boot_steps: + - "apt-get update" + - "apt-get install -y sudo locales policykit-1" + - "sed -i 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen" + - "locale-gen en_US.UTF-8" + - "printf 'LANG=en_US.UTF-8\\nLANGUAGE=en_US:en\\nLC_ALL=en_US.UTF-8\\n' > /etc/default/locale" + - "ln -sf /usr/share/zoneinfo/UTC /etc/localtime" + - "useradd -m -s /bin/bash tiny" + - "echo 'tiny:tiny' | chpasswd" + - "echo 'root:tiny' | chpasswd" + - "echo 'tiny ALL=(ALL:ALL) NOPASSWD:ALL' >> /etc/sudoers" + - "chmod 0440 /etc/sudoers" + - "mkdir -p /etc/polkit-1/rules.d" + - "printf 'polkit.addRule(function(action, subject) {\\n if (subject.user == \"tiny\") {\\n return polkit.Result.YES;\\n }\\n});\\n' > /etc/polkit-1/rules.d/99-allow-all.rules" + + - alias: ubuntu + display_name: "Ubuntu" + package_manager: apt + source: + type: oci + registry: "registry-1.docker.io" + auth_realm: "https://auth.docker.io/token" + auth_service: "registry.docker.io" + repository: "library/ubuntu" + tag: "noble" + first_boot_steps: + - "apt-get update" + - "apt-get install -y sudo locales policykit-1" + - "locale-gen en_US.UTF-8" + - "printf 'LANG=en_US.UTF-8\\nLANGUAGE=en_US:en\\nLC_ALL=en_US.UTF-8\\n' > /etc/default/locale" + - "ln -sf /usr/share/zoneinfo/UTC /etc/localtime" + - "useradd -m -s /bin/bash tiny" + - "echo 'tiny:tiny' | chpasswd" + - "echo 'root:tiny' | chpasswd" + - "echo 'tiny ALL=(ALL:ALL) NOPASSWD:ALL' >> /etc/sudoers" + - "chmod 0440 /etc/sudoers" + - "mkdir -p /etc/polkit-1/rules.d" + - "printf 'polkit.addRule(function(action, subject) {\\n if (subject.user == \"tiny\") {\\n return polkit.Result.YES;\\n }\\n});\\n' > /etc/polkit-1/rules.d/99-allow-all.rules" + + - alias: fedora + display_name: "Fedora" + package_manager: dnf + source: + type: oci + registry: "registry-1.docker.io" + auth_realm: "https://auth.docker.io/token" + auth_service: "registry.docker.io" + repository: "library/fedora" + tag: "latest" + first_boot_steps: + - "dnf install -y sudo passwd shadow-utils glibc-langpack-en polkit" + - "localedef -i en_US -f UTF-8 en_US.UTF-8" + - "echo 'LANG=en_US.UTF-8' > /etc/locale.conf" + - "ln -sf /usr/share/zoneinfo/UTC /etc/localtime" + - "useradd -m -s /bin/bash tiny" + - "echo 'tiny:tiny' | chpasswd" + - "echo 'root:tiny' | chpasswd" + - "echo '%wheel ALL=(ALL:ALL) NOPASSWD: ALL' > /etc/sudoers.d/wheel" + - "gpasswd -a tiny wheel" + - "chmod 0440 /etc/sudoers.d/wheel" + - "mkdir -p /etc/polkit-1/rules.d" + - "printf 'polkit.addRule(function(action, subject) {\\n if (subject.user == \"tiny\") {\\n return polkit.Result.YES;\\n }\\n});\\n' > /etc/polkit-1/rules.d/99-allow-all.rules" 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..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 @@ -165,6 +167,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 +221,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 new file mode 100644 index 0000000..161360d --- /dev/null +++ b/app/src/main/java/com/fct/tc4/ui/misc/DeInstaller.kt @@ -0,0 +1,111 @@ +// DeInstaller.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 + +/** + * Builds the package-manager-appropriate install command for a [DeEntry] + * inside an already-installed container, then runs it via [ProotExec]. + * + * Tier-2 (experimental/Wayland) entries are currently pacman-only by + * design — see de_packages.yaml's tier-2 header comment for why (Fedora + * lacks niri/cosmic-desktop in its official repos; Debian/Ubuntu's apt + * situation wasn't verified to the same standard as tier 1). This class + * refuses a tier-2 install for any other package manager rather than + * silently attempting something unverified, per the task brief's "flag + * simpler alternatives before building harder ones" requirement. + */ +class DeInstaller(private val execShell: suspend (block: () -> Unit) -> Int) { + + sealed interface Result { + data class Installed(val de: DeEntry) : Result + data class Refused(val de: DeEntry, val reason: String) : Result + } + + /** What [dryRun] reports, without running anything. */ + data class DryRunReport( + val de: DeEntry, + val packageManager: PackageManagerFamily, + val packages: List?, + val installCommandPreview: String?, + val refusalReason: String?, + ) + + fun dryRun(de: DeEntry, distro: DistroEntry): DryRunReport { + val family = distro.packageManager + val refusal = refusalReasonFor(de, family) + val packages = de.packagesFor(family) + return DryRunReport( + de = de, + packageManager = family, + packages = packages, + installCommandPreview = packages?.let { installCommand(family, it) }, + refusalReason = refusal, + ) + } + + suspend fun install(de: DeEntry, distro: DistroEntry, containerDir: String): Result { + val family = distro.packageManager + refusalReasonFor(de, family)?.let { reason -> + return Result.Refused(de, reason) + } + + val packages = de.packagesFor(family) + ?: return Result.Refused( + de, + "No ${family.name.lowercase()} package list for '${de.alias}' in de_packages.yaml", + ) + + execShell { + Global.setupEnvironment() + Global.sendCommand( + ProotExec.runAsRootCommand(containerDir, installCommand(family, packages)), + ) + Global.sendCommand("exit") + } + return Result.Installed(de) + } + + private fun refusalReasonFor(de: DeEntry, family: PackageManagerFamily): String? { + if (de.tier != DeTier.EXPERIMENTAL) return null + if (family == PackageManagerFamily.PACMAN) return null + return "'${de.alias}' is tier-2/experimental and has only been prototyped on Arch (pacman) " + + "so far — installing it on ${family.name.lowercase()} is refused rather than guessed. " + + "Simpler fallback: pick a tier-1 DE, or switch this container's distro to Arch Linux." + } + + private fun installCommand(family: PackageManagerFamily, packages: List): String { + val list = packages.joinToString(" ") + return when (family) { + PackageManagerFamily.PACMAN -> "pacman -Sy --noconfirm $list" + PackageManagerFamily.APT -> "apt-get update && apt-get install -y $list" + PackageManagerFamily.DNF -> { + // "@group-id" is dnf4's group-install syntax. Not verified + // against whatever dnf major version actually ends up + // inside the Fedora base image — dnf5 changed group-command + // syntax in places. Needs a real-device check. + val groups = packages.filter { it.startsWith("@") } + val plain = packages.filter { !it.startsWith("@") } + buildString { + groups.forEach { append("dnf group install -y \"$it\" && ") } + if (plain.isNotEmpty()) append("dnf install -y ${plain.joinToString(" ")}") + else if (groups.isNotEmpty()) setLength(length - 4) // trim trailing " && " + } + } + } + } +} 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 new file mode 100644 index 0000000..fd3b4cb --- /dev/null +++ b/app/src/main/java/com/fct/tc4/ui/misc/DeRegistry.kt @@ -0,0 +1,99 @@ +// DeRegistry.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 + +import android.content.Context +import org.yaml.snakeyaml.Yaml + +enum class DeTier { STABLE, EXPERIMENTAL } + +data class DeEntry( + val alias: String, + val displayName: String, + val tier: DeTier, + /** Only meaningful for [DeTier.EXPERIMENTAL] entries — see DeInstaller.kt. */ + 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] +} + +/** + * Loads the config-driven DE list from assets/de_packages.yaml. Adding a new + * DE (tier 1 or tier 2) is a manifest edit only. + */ +object DeRegistry { + + private var cached: List? = null + + fun load(context: Context): List { + cached?.let { return it } + + @Suppress("UNCHECKED_CAST") + val root = context.assets.open("de_packages.yaml").use { stream -> + Yaml().load>(stream) + } + + @Suppress("UNCHECKED_CAST") + val rawList = root["des"] as? List> + ?: throw IllegalStateException("de_packages.yaml is missing a top-level 'des' list") + + val entries = rawList.map { raw -> parseEntry(raw) } + cached = entries + return entries + } + + fun findByAlias(context: Context, alias: String): DeEntry? = + load(context).firstOrNull { it.alias == alias } + + fun byTier(context: Context, tier: DeTier): List = + load(context).filter { it.tier == tier } + + @Suppress("UNCHECKED_CAST") + private fun parseEntry(raw: Map): DeEntry { + val alias = raw["alias"] as? String + ?: throw IllegalStateException("DE entry missing 'alias': $raw") + val displayName = raw["display_name"] as? String + ?: throw IllegalStateException("DE '$alias' missing 'display_name'") + val tier = when (val t = raw["tier"]) { + 1 -> DeTier.STABLE + 2 -> DeTier.EXPERIMENTAL + else -> throw IllegalStateException("DE '$alias' has unknown tier: $t") + } + val requiresBridge = raw["requires_bridge"] as? Boolean ?: false + val packageManagersRaw = raw["package_managers"] as? Map> + ?: throw IllegalStateException("DE '$alias' missing 'package_managers'") + 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, 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 new file mode 100644 index 0000000..0180ce0 --- /dev/null +++ b/app/src/main/java/com/fct/tc4/ui/misc/DistroInstaller.kt @@ -0,0 +1,238 @@ +// DistroInstaller.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 + +import android.app.Application +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +/** + * Fetches a [DistroEntry]'s base rootfs (via [OciRegistryClient] for `oci` + * sources, or a plain HTTP(S) download for `static_tarball` sources) and + * extracts it using the exact same `proot --link2symlink tar` pipeline + * already used for the app's bundled rootfs.tar.zst + * (see ContainerManageViewModel.performInstall()), then runs the distro's + * `first_boot_steps`. + * + * The caller supplies [execShell], which should be the same + * "run this in a fresh terminal session and suspend until it exits" + * function ContainerManageViewModel already uses — this class only builds + * the commands, it doesn't own the terminal session. + */ +class DistroInstaller( + private val app: Application, + private val execShell: suspend (block: () -> Unit) -> Int, +) { + sealed interface Progress { + data class Resolving(val distro: DistroEntry) : Progress + data class Downloading(val distro: DistroEntry, val bytesDownloaded: Long, val totalBytes: Long) : Progress + data class Extracting(val distro: DistroEntry) : Progress + data class RunningFirstBootStep( + val distro: DistroEntry, + val stepIndex: Int, + val totalSteps: Int, + val command: String, + ) : Progress + data class Completed(val distro: DistroEntry, val code: String) : Progress + } + + /** What [dryRun] reports, without downloading/extracting/running anything. */ + data class DryRunReport( + val distro: DistroEntry, + val resolvedSizeBytes: Long?, + val sourceDescription: String, + val extractionCommandPreview: String, + val firstBootStepsPreview: List, + ) + + /** + * Resolves what an install WOULD do — real network calls to find the + * real download size (OCI manifest lookup, or a HEAD request for static + * tarballs), but no download, no extraction, no first-boot commands run. + */ + suspend fun dryRun(distro: DistroEntry): DryRunReport { + val (sizeBytes, description) = when (val source = distro.source) { + is DistroSource.StaticTarball -> { + val size = headContentLength(source.url) + size to buildString { + append("static tarball: ${source.url}") + if (size != null) append(" (${formatBytes(size)})") + else append(" (size unknown — server did not report Content-Length on HEAD)") + } + } + is DistroSource.Oci -> { + val token = OciRegistryClient.fetchPullToken(source) + val layer = OciRegistryClient.resolveArm64Layer(source, token) + layer.sizeBytes to + "OCI ${source.repository}:${source.tag} — layer ${layer.digest} (${formatBytes(layer.sizeBytes)})" + } + } + + return DryRunReport( + distro = distro, + resolvedSizeBytes = sizeBytes, + sourceDescription = description, + extractionCommandPreview = extractionCommand(distro.alias), + firstBootStepsPreview = distro.firstBootSteps, + ) + } + + /** Actually installs [distro] into a new container directory named [code]. */ + suspend fun install( + distro: DistroEntry, + code: String, + onProgress: (Progress) -> Unit = {}, + ) { + onProgress(Progress.Resolving(distro)) + val cacheFile = File(app.cacheDir, "distro-rootfs-$code.tar.gz") + + when (val source = distro.source) { + is DistroSource.StaticTarball -> downloadPlain(source.url, cacheFile) { downloaded, total -> + onProgress(Progress.Downloading(distro, downloaded, total)) + } + is DistroSource.Oci -> { + val token = OciRegistryClient.fetchPullToken(source) + val layer = OciRegistryClient.resolveArm64Layer(source, token) + OciRegistryClient.downloadLayerBlob(source, layer, token, cacheFile) { downloaded, total -> + onProgress(Progress.Downloading(distro, downloaded, total)) + } + } + } + + onProgress(Progress.Extracting(distro)) + val dir = File(app.dataDir, code) + 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.runAsRootCommand(dir.absolutePath, step)) + } + 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") + + private fun extractionCommand(code: String, tarballPath: String): String = $$""" + export CONTAINER_DIR=$${app.dataDir}/$$code + mkdir -p $CONTAINER_DIR + $BIN_DIR/proot --link2symlink $BIN_DIR/tar -xzf $$tarballPath -C $CONTAINER_DIR --delay-directory-restore --preserve-permissions + """.trimIndent() + + private suspend fun headContentLength(url: String): Long? = withContext(Dispatchers.IO) { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "HEAD" + instanceFollowRedirects = true + connectTimeout = 15_000 + readTimeout = 15_000 + } + try { + connection.connect() + connection.contentLengthLong.takeIf { it >= 0 } + } catch (_: Exception) { + null + } finally { + connection.disconnect() + } + } + + private suspend fun downloadPlain( + url: String, + destFile: File, + onProgress: (downloaded: Long, total: Long) -> Unit, + ) = withContext(Dispatchers.IO) { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + instanceFollowRedirects = true + connectTimeout = 15_000 + readTimeout = 30_000 + } + try { + check(connection.responseCode == HttpURLConnection.HTTP_OK) { + "Download failed: HTTP ${connection.responseCode} for $url" + } + val total = connection.contentLengthLong + var downloaded = 0L + connection.inputStream.use { input -> + destFile.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + downloaded += read + onProgress(downloaded, total) + } + } + } + } finally { + connection.disconnect() + } + } + + private fun formatBytes(bytes: Long): String { + val mb = bytes / (1024.0 * 1024.0) + return "%.1f MB".format(mb) + } +} diff --git a/app/src/main/java/com/fct/tc4/ui/misc/DistroRegistry.kt b/app/src/main/java/com/fct/tc4/ui/misc/DistroRegistry.kt new file mode 100644 index 0000000..c938f63 --- /dev/null +++ b/app/src/main/java/com/fct/tc4/ui/misc/DistroRegistry.kt @@ -0,0 +1,124 @@ +// DistroRegistry.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 + +import android.content.Context +import org.yaml.snakeyaml.Yaml + +/** One of the package-manager families a distro entry can declare. */ +enum class PackageManagerFamily { + PACMAN, DNF, APT; + + companion object { + fun fromString(value: String): PackageManagerFamily = + entries.firstOrNull { it.name.equals(value, ignoreCase = true) } + ?: throw IllegalArgumentException("Unknown package_manager: $value") + } +} + +/** Where a distro's base rootfs actually comes from. See distros.yaml's header comment. */ +sealed interface DistroSource { + data class StaticTarball(val url: String) : DistroSource + + data class Oci( + val registry: String, + val authRealm: String, + val authService: String, + val repository: String, + val tag: String, + ) : DistroSource +} + +data class DistroEntry( + val alias: String, + val displayName: String, + val packageManager: PackageManagerFamily, + val source: DistroSource, + val firstBootSteps: List, +) + +/** + * Loads the config-driven distro list from assets/distros.yaml. Adding a new + * distro is a manifest edit only — nothing here or in the selector UI needs + * to change. + */ +object DistroRegistry { + + private var cached: List? = null + + fun load(context: Context): List { + cached?.let { return it } + + @Suppress("UNCHECKED_CAST") + val root = context.assets.open("distros.yaml").use { stream -> + Yaml().load>(stream) + } + + @Suppress("UNCHECKED_CAST") + val rawList = root["distros"] as? List> + ?: throw IllegalStateException("distros.yaml is missing a top-level 'distros' list") + + val entries = rawList.map { raw -> parseEntry(raw) } + cached = entries + return entries + } + + /** Look up a single entry by alias, or null if the manifest has no such distro. */ + fun findByAlias(context: Context, alias: String): DistroEntry? = + load(context).firstOrNull { it.alias == alias } + + @Suppress("UNCHECKED_CAST") + private fun parseEntry(raw: Map): DistroEntry { + val alias = raw["alias"] as? String + ?: throw IllegalStateException("distro entry missing 'alias': $raw") + val displayName = raw["display_name"] as? String + ?: throw IllegalStateException("distro '$alias' missing 'display_name'") + val packageManager = PackageManagerFamily.fromString( + raw["package_manager"] as? String + ?: throw IllegalStateException("distro '$alias' missing 'package_manager'") + ) + val sourceRaw = raw["source"] as? Map + ?: throw IllegalStateException("distro '$alias' missing 'source'") + val source = parseSource(alias, sourceRaw) + val firstBootSteps = (raw["first_boot_steps"] as? List) ?: emptyList() + + return DistroEntry(alias, displayName, packageManager, source, firstBootSteps) + } + + private fun parseSource(alias: String, raw: Map): DistroSource { + return when (val type = raw["type"] as? String) { + "static_tarball" -> DistroSource.StaticTarball( + url = raw["url"] as? String + ?: throw IllegalStateException("distro '$alias' static_tarball source missing 'url'") + ) + "oci" -> DistroSource.Oci( + registry = raw["registry"] as? String + ?: throw IllegalStateException("distro '$alias' oci source missing 'registry'"), + authRealm = raw["auth_realm"] as? String + ?: throw IllegalStateException("distro '$alias' oci source missing 'auth_realm'"), + authService = raw["auth_service"] as? String + ?: throw IllegalStateException("distro '$alias' oci source missing 'auth_service'"), + repository = raw["repository"] as? String + ?: throw IllegalStateException("distro '$alias' oci source missing 'repository'"), + tag = raw["tag"] as? String + ?: throw IllegalStateException("distro '$alias' oci source missing 'tag'"), + ) + else -> throw IllegalStateException("distro '$alias' has unknown source type: $type") + } + } +} diff --git a/app/src/main/java/com/fct/tc4/ui/misc/OciRegistryClient.kt b/app/src/main/java/com/fct/tc4/ui/misc/OciRegistryClient.kt new file mode 100644 index 0000000..5f7e220 --- /dev/null +++ b/app/src/main/java/com/fct/tc4/ui/misc/OciRegistryClient.kt @@ -0,0 +1,158 @@ +// OciRegistryClient.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 + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +/** + * A small, purpose-built Docker Registry v2 (OCI distribution spec) HTTP + * client — NOT upstream proot-distro. proot-distro's own 2026 rewrite moved + * to Python and this app does not bundle a Python runtime, so distro pulls + * for [DistroSource.Oci] entries go through this instead. It only implements + * the handful of calls needed to resolve an anonymous-pull public image tag + * to its single arm64 layer blob and stream that blob to a file — it is not + * a general OCI/registry client (no auth beyond anonymous pull tokens, no + * multi-layer image support, since every currently-configured OCI distro in + * distros.yaml was verified to resolve to exactly one layer). + */ +object OciRegistryClient { + + data class LayerInfo(val digest: String, val sizeBytes: Long) + + private const val ARCH = "arm64" + private const val CONNECT_TIMEOUT_MS = 15_000 + private const val READ_TIMEOUT_MS = 30_000 + + /** Fetches a short-lived anonymous pull token for [source]'s repository. */ + suspend fun fetchPullToken(source: DistroSource.Oci): String = withContext(Dispatchers.IO) { + val url = "${source.authRealm}?service=${source.authService}" + + "&scope=repository:${source.repository}:pull" + val body = httpGetString(url, emptyMap()) + JSONObject(body).getString("token") + } + + /** + * Resolves [source]'s tag to the single arm64 layer that image tag + * actually contains. Two HTTP round-trips: the manifest list (to find + * the arm64-specific manifest's digest), then that manifest (to read + * its layer list). Throws if the image isn't exactly one layer, since + * that's the only shape this client supports (see class kdoc). + */ + suspend fun resolveArm64Layer(source: DistroSource.Oci, token: String): LayerInfo = + withContext(Dispatchers.IO) { + val manifestListUrl = + "https://${source.registry}/v2/${source.repository}/manifests/${source.tag}" + val listAccept = "application/vnd.oci.image.index.v1+json," + + "application/vnd.docker.distribution.manifest.list.v2+json" + val listBody = httpGetString( + manifestListUrl, + mapOf("Authorization" to "Bearer $token", "Accept" to listAccept), + ) + val manifests = JSONObject(listBody).getJSONArray("manifests") + var archDigest: String? = null + for (i in 0 until manifests.length()) { + val entry = manifests.getJSONObject(i) + val platform = entry.optJSONObject("platform") ?: continue + if (platform.optString("architecture") == ARCH) { + archDigest = entry.getString("digest") + break + } + } + requireNotNull(archDigest) { + "No $ARCH manifest found for ${source.repository}:${source.tag}" + } + + val manifestUrl = "https://${source.registry}/v2/${source.repository}/manifests/$archDigest" + val manifestAccept = "application/vnd.oci.image.manifest.v1+json," + + "application/vnd.docker.distribution.manifest.v2+json" + val manifestBody = httpGetString( + manifestUrl, + mapOf("Authorization" to "Bearer $token", "Accept" to manifestAccept), + ) + val layers = JSONObject(manifestBody).getJSONArray("layers") + require(layers.length() == 1) { + "Expected exactly 1 layer for ${source.repository}:${source.tag}, found ${layers.length()}" + } + val layer = layers.getJSONObject(0) + LayerInfo(layer.getString("digest"), layer.getLong("size")) + } + + /** + * Streams the given layer blob to [destFile], calling [onProgress] with + * bytes-downloaded-so-far as it goes. The blob is itself a gzip tarball + * (application/vnd.oci.image.layer.v1.tar+gzip) — callers extract it + * exactly like the app's existing bundled rootfs.tar.zst, just gzip + * instead of zstd. + */ + suspend fun downloadLayerBlob( + source: DistroSource.Oci, + layer: LayerInfo, + token: String, + destFile: File, + onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit = { _, _ -> }, + ) = withContext(Dispatchers.IO) { + val url = URL("https://${source.registry}/v2/${source.repository}/blobs/${layer.digest}") + val connection = (url.openConnection() as HttpURLConnection).apply { + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + setRequestProperty("Authorization", "Bearer $token") + instanceFollowRedirects = true + } + try { + check(connection.responseCode == HttpURLConnection.HTTP_OK) { + "Blob download failed: HTTP ${connection.responseCode} for ${layer.digest}" + } + var downloaded = 0L + connection.inputStream.use { input -> + destFile.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + downloaded += read + onProgress(downloaded, layer.sizeBytes) + } + } + } + } finally { + connection.disconnect() + } + } + + private fun httpGetString(url: String, headers: Map): String { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + headers.forEach { (key, value) -> setRequestProperty(key, value) } + } + try { + check(connection.responseCode == HttpURLConnection.HTTP_OK) { + "GET $url failed: HTTP ${connection.responseCode}" + } + return connection.inputStream.bufferedReader().use { it.readText() } + } finally { + connection.disconnect() + } + } +} 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 new file mode 100644 index 0000000..ef08257 --- /dev/null +++ b/app/src/main/java/com/fct/tc4/ui/misc/ProotExec.kt @@ -0,0 +1,121 @@ +// ProotExec.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 + +/** + * 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). + * + * 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 { + + /** 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 $$""" + 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