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..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/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/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