diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt index e8567d5..14e87cb 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt @@ -51,7 +51,10 @@ import java.util.concurrent.atomic.AtomicReference class RuntimeSupervisorService : Service() { private val binder = RuntimeBinder() private val mainHandler = Handler(Looper.getMainLooper()) - private val ownedSession = AtomicReference(null) + private val terminalTabs = TerminalTabRegistry() + private val closingTerminalIds = mutableSetOf() + private var nextTerminalNumber = 1 + private var terminalCreationInFlight = false private val ownedDesktop = AtomicReference(null) private val desktopLaunchToken = AtomicReference(null) private val pendingDesktopRestart = AtomicReference(null) @@ -239,6 +242,8 @@ class RuntimeSupervisorService : Service() { override fun onDestroy() { attachedViews.clear() mainHandler.removeCallbacksAndMessages(null) + terminalTabs.clear().forEach { it.value.finishIfRunning() } + closingTerminalIds.clear() pendingDesktopRestart.set(null) desktopLaunchToken.set(null) ownedDesktop.getAndSet(null)?.let { terminateDesktopProcess(it, OsConstants.SIGKILL) } @@ -257,7 +262,135 @@ class RuntimeSupervisorService : Service() { super.onDestroy() } - fun currentTerminalSession(): TerminalSession? = ownedSession.get() + fun currentTerminalSession(): TerminalSession? = terminalTabs.active()?.value + + fun terminalTabSnapshots(): List = + terminalTabs.all().map { tab -> + TerminalTabSnapshot( + id = tab.id, + title = tab.title, + rootfsName = tab.rootfsName, + pid = tab.value.pid.takeIf { it > 0 }?.toLong(), + running = tab.value.isRunning, + active = tab.id == terminalTabs.activeId, + ) + } + + fun createTerminalTab( + rootfsName: String, + onComplete: (Result) -> Unit, + ) { + check(Looper.myLooper() == Looper.getMainLooper()) { + "Terminal tabs must be created from the main thread" + } + if (terminalCreationInFlight) { + onComplete(Result.failure(IllegalStateException("A terminal is already opening"))) + return + } + val snapshot = app.runtimeState.current() + if (snapshot.phase != RuntimePhase.RUNNING) { + onComplete(Result.failure(IllegalStateException("Start Linux before opening another terminal"))) + return + } + terminalCreationInFlight = true + val x11SocketDirectory = x11Controller.activeSocketDirectory() + val audioEndpoint = audioController.endpoint() + applicationExecutor.execute { + val prepared = + runCatching { + val rootfs = + app.rootfsRegistry + .all() + .firstOrNull { it.name == rootfsName } + ?.directory + ?: error("Linux system $rootfsName is not installed or is not ready") + ProotTerminalLaunchBuilder.create( + context = this, + runtime = ProotRuntimeInstaller.install(this), + rootfs = rootfs, + x11SocketDirectory = x11SocketDirectory, + audioEndpoint = audioEndpoint, + ) + } + mainHandler.post { + val result = + prepared.mapCatching { launch -> + val current = app.runtimeState.current() + check(current.phase == RuntimePhase.RUNNING) { + "Linux stopped while the terminal was opening" + } + val tab = createTerminalSession(launch) + attachActiveTerminalToViews() + publishTerminalTabState("${tab.title} is ready") + app.journal.append( + component = "terminal", + severity = "info", + event = "tab_created", + message = "Created ${tab.title}", + bootId = current.bootId, + fields = + mapOf( + "tab_id" to tab.id, + "pid" to tab.value.pid, + "rootfs" to tab.rootfsName, + ), + ) + tab.id + }.onFailure { error -> + app.journal.append( + component = "terminal", + severity = "error", + event = "tab_create_failed", + message = error.message ?: "Could not create terminal tab", + bootId = app.runtimeState.current().bootId, + fields = mapOf("exception" to error.javaClass.name), + ) + } + terminalCreationInFlight = false + onComplete(result) + } + } + } + + fun selectTerminalTab(id: String): Boolean { + val tab = terminalTabs.select(id) ?: return false + attachedViews.forEach { view -> + if (view.mTermSession !== tab.value) view.attachSession(tab.value) + view.onScreenUpdated() + } + publishTerminalTabState("${tab.title} selected") + return true + } + + fun renameTerminalTab( + id: String, + requestedTitle: String, + ): Boolean { + val title = requestedTitle.trim().take(MAX_TERMINAL_TITLE_CHARS) + if (title.isBlank()) return false + val tab = terminalTabs.rename(id, title) ?: return false + publishTerminalTabState("Renamed terminal to ${tab.title}") + app.journal.append( + component = "terminal", + severity = "info", + event = "tab_renamed", + message = "Renamed terminal tab", + bootId = app.runtimeState.current().bootId, + fields = mapOf("tab_id" to id, "title" to title), + ) + return true + } + + fun closeTerminalTab(id: String): Boolean { + val tab = terminalTabs.get(id) ?: return false + if (terminalTabs.size() == 1) { + stopRuntime(userRequested = true) + return true + } + closingTerminalIds += id + terminateTerminalTab(tab) + return true + } fun currentDesktopSession(): DesktopSessionSnapshot = app.runtimeState.current().desktop @@ -268,7 +401,7 @@ class RuntimeSupervisorService : Service() { configuration: AudioConfiguration, callback: (Result) -> Unit, ) { - val session = ownedSession.get() + val session = currentTerminalSession() if (session?.isRunning != true || session.mSessionName != rootfsName) { callback( Result.failure( @@ -327,7 +460,7 @@ class RuntimeSupervisorService : Service() { "Terminal views must attach on the main thread" } attachedViews += view - ownedSession.get()?.let { session -> + currentTerminalSession()?.let { session -> if (view.mTermSession !== session) { view.attachSession(session) } @@ -340,7 +473,7 @@ class RuntimeSupervisorService : Service() { } fun writeToTerminal(text: String) { - ownedSession.get()?.takeIf(TerminalSession::isRunning)?.write(text) + currentTerminalSession()?.takeIf(TerminalSession::isRunning)?.write(text) } fun requestX11RendererConnection(callback: (ParcelFileDescriptor?) -> Unit) { @@ -392,12 +525,12 @@ class RuntimeSupervisorService : Service() { val snapshot = app.runtimeState.current() if ( snapshot.phase != RuntimePhase.RUNNING || - ownedSession.get()?.isRunning != true + currentTerminalSession()?.isRunning != true ) { callback(Result.failure(IllegalStateException("Start Linux before launching an app"))) return } - if (ownedSession.get()?.mSessionName != rootfsName) { + if (currentTerminalSession()?.mSessionName != rootfsName) { callback( Result.failure( IllegalStateException("Switch to $rootfsName before launching ${application.name}"), @@ -487,7 +620,7 @@ class RuntimeSupervisorService : Service() { private fun startDesktopInternal(request: DesktopLaunchRequest) { val runtime = app.runtimeState.current() - val terminal = ownedSession.get() + val terminal = currentTerminalSession() if ( runtime.phase != RuntimePhase.RUNNING || terminal?.isRunning != true || @@ -909,14 +1042,14 @@ class RuntimeSupervisorService : Service() { audioConfiguration: AudioConfiguration = effectiveAudioConfiguration(requestedRootfsName, allowMicrophone = false), ) { - val existing = ownedSession.get() - if (existing?.isRunning == true && existing.pid > 0) { - if (requestedRootfsName != null && existing.mSessionName != requestedRootfsName) { + val existing = terminalTabs.active() + if (existing?.value?.isRunning == true && existing.value.pid > 0) { + if (requestedRootfsName != null && existing.rootfsName != requestedRootfsName) { publishState( app.runtimeState.update { it.copy( message = - "${existing.mSessionName} is running; stop it before switching " + + "${existing.rootfsName} is running; stop it before switching " + "to $requestedRootfsName", ) }, @@ -928,17 +1061,17 @@ class RuntimeSupervisorService : Service() { it.copy( phase = RuntimePhase.RUNNING, desiredRunning = true, - message = "Linux terminal is already running · PID ${existing.pid}", - childPid = existing.pid.toLong(), - rootfsName = existing.mSessionName, + message = "${terminalTabs.size()} terminal tab(s) running", + childPid = existing.value.pid.toLong(), + rootfsName = existing.rootfsName, ) }, ) return } - if (existing != null) { - ownedSession.compareAndSet(existing, null) - } + terminalTabs.clear().forEach { it.value.finishIfRunning() } + closingTerminalIds.clear() + nextTerminalNumber = 1 val bootId = UUID.randomUUID().toString() publishState( @@ -982,86 +1115,118 @@ class RuntimeSupervisorService : Service() { audioEndpoint = audioController.endpoint(), ) }.mapCatching { launch -> - configureTerminalColors() - val session = - TerminalSession( - launch.executable, - launch.workingDirectory, - launch.arguments, - launch.environment, - TRANSCRIPT_ROWS, - terminalClient, + launch to createTerminalSession(launch) + } + .onSuccess { (launch, tab) -> + attachActiveTerminalToViews() + val session = tab.value + val running = + app.runtimeState.update { + it.copy( + phase = RuntimePhase.RUNNING, + desiredRunning = true, + message = "${tab.title} is running · PID ${session.pid}", + childPid = session.pid.toLong(), + rootfsName = launch.rootfs.name, + ) + } + publishState(running) + updateNotification("Linux terminal is running") + app.journal.append( + component = "terminal", + severity = "info", + event = "session_started", + message = "Interactive PRoot terminal started", + bootId = bootId, + fields = + mapOf( + "pid" to session.pid, + "rootfs" to launch.rootfs.name, + "tab_id" to tab.id, + "terminal" to "termux-v0.118.3", + ), ) - session.mSessionName = launch.rootfs.name - check(ownedSession.compareAndSet(null, session)) { - "Another terminal session won the ownership race" - } - try { - session.updateSize( - DEFAULT_COLUMNS, - DEFAULT_ROWS, - DEFAULT_CELL_WIDTH_PX, - DEFAULT_CELL_HEIGHT_PX, + }.onFailure { error -> + val failed = + app.runtimeState.update { + it.copy( + phase = RuntimePhase.CRASHED, + desiredRunning = false, + message = error.message ?: error.javaClass.simpleName, + childPid = null, + ) + } + publishState(failed) + app.journal.append( + component = "supervisor", + severity = "error", + event = "terminal_start_failed", + message = failed.message, + bootId = bootId, + fields = mapOf("exception" to error.javaClass.name), ) - } catch (error: Throwable) { - ownedSession.compareAndSet(session, null) - if (session.pid > 0) session.finishIfRunning() - throw error + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() } - check(session.pid > 0) { "Termux did not return a terminal child PID" } - launch to session - }.onSuccess { (launch, session) -> - attachedViews.forEach { view -> - view.attachSession(session) - view.onScreenUpdated() - } - val running = - app.runtimeState.update { - it.copy( - phase = RuntimePhase.RUNNING, - desiredRunning = true, - message = "${launch.rootfs.name} terminal is running · PID ${session.pid}", - childPid = session.pid.toLong(), - rootfsName = launch.rootfs.name, - ) - } - publishState(running) - updateNotification("Linux terminal is running") - app.journal.append( - component = "terminal", - severity = "info", - event = "session_started", - message = "Interactive PRoot terminal started", - bootId = bootId, - fields = - mapOf( - "pid" to session.pid, - "rootfs" to launch.rootfs.name, - "terminal" to "termux-v0.118.3", - ), + } + + private fun createTerminalSession(launch: ProotTerminalLaunch): TerminalTab { + configureTerminalColors() + val session = + TerminalSession( + launch.executable, + launch.workingDirectory, + launch.arguments, + launch.environment, + TRANSCRIPT_ROWS, + terminalClient, ) - }.onFailure { error -> - val failed = - app.runtimeState.update { - it.copy( - phase = RuntimePhase.CRASHED, - desiredRunning = false, - message = error.message ?: error.javaClass.simpleName, - childPid = null, - ) - } - publishState(failed) - app.journal.append( - component = "supervisor", - severity = "error", - event = "terminal_start_failed", - message = failed.message, - bootId = bootId, - fields = mapOf("exception" to error.javaClass.name), + session.mSessionName = launch.rootfs.name + val tab = + TerminalTab( + id = session.mHandle, + title = "Terminal ${nextTerminalNumber++}", + rootfsName = launch.rootfs.name, + value = session, ) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() + terminalTabs.add(tab) + try { + session.updateSize( + DEFAULT_COLUMNS, + DEFAULT_ROWS, + DEFAULT_CELL_WIDTH_PX, + DEFAULT_CELL_HEIGHT_PX, + ) + check(session.pid > 0) { "Termux did not return a terminal child PID" } + } catch (error: Throwable) { + terminalTabs.remove(tab.id) + if (session.pid > 0) session.finishIfRunning() + throw error } + return tab + } + + private fun attachActiveTerminalToViews() { + val session = currentTerminalSession() ?: return + attachedViews.forEach { view -> + if (view.mTermSession !== session) view.attachSession(session) + view.onScreenUpdated() + } + } + + private fun publishTerminalTabState(message: String) { + val active = terminalTabs.active() + val next = + app.runtimeState.update { + it.copy( + phase = if (active == null) it.phase else RuntimePhase.RUNNING, + desiredRunning = active != null || it.desiredRunning, + message = message, + childPid = active?.value?.pid?.takeIf { pid -> pid > 0 }?.toLong(), + rootfsName = active?.rootfsName ?: it.rootfsName, + ) + } + publishState(next) } private fun configureTerminalColors() { @@ -1078,11 +1243,59 @@ class RuntimeSupervisorService : Service() { } private fun handleSessionFinished(session: TerminalSession) { - if (!ownedSession.compareAndSet(session, null)) return - attachedViews.forEach(TerminalView::onScreenUpdated) + val tab = terminalTabs.findByValue(session) ?: return + val individuallyClosed = closingTerminalIds.remove(tab.id) + terminalTabs.remove(tab.id) val exitCode = session.exitStatus val beforeExit = app.runtimeState.current() val expected = !beforeExit.desiredRunning || beforeExit.phase == RuntimePhase.STOPPING + + if (terminalTabs.size() > 0) { + attachActiveTerminalToViews() + val active = terminalTabs.active() + val detail = + if (individuallyClosed || expected) { + "${tab.title} closed · ${terminalTabs.size()} tab(s) remaining" + } else { + "${tab.title} exited with code $exitCode · ${terminalTabs.size()} tab(s) remaining" + } + if (beforeExit.phase == RuntimePhase.STOPPING) { + publishState( + app.runtimeState.update { + it.copy( + message = "Stopping ${terminalTabs.size()} remaining terminal tab(s)", + childPid = active?.value?.pid?.takeIf { pid -> pid > 0 }?.toLong(), + ) + }, + ) + } else { + publishTerminalTabState(detail) + } + app.journal.append( + component = "terminal", + severity = if (individuallyClosed || expected) "info" else "error", + event = if (individuallyClosed || expected) "tab_closed" else "tab_crashed", + message = + if (individuallyClosed || expected) { + "Terminal tab closed" + } else { + "Terminal tab exited unexpectedly with code $exitCode" + }, + bootId = beforeExit.bootId, + fields = + mapOf( + "tab_id" to tab.id, + "title" to tab.title, + "exit_code" to exitCode, + "active_tab_id" to active?.id, + "remaining_tabs" to terminalTabs.size(), + ), + ) + updateNotification("${terminalTabs.size()} Linux terminal tabs are running") + return + } + + attachedViews.forEach(TerminalView::onScreenUpdated) pendingDesktopRestart.set(null) stopDesktopProcess(restarting = false) val next = @@ -1136,13 +1349,23 @@ class RuntimeSupervisorService : Service() { fields = mapOf("user_requested" to userRequested), ) - val session = ownedSession.get() - if (session == null || !session.isRunning || session.pid < 1) { - ownedSession.compareAndSet(session, null) + val sessions = terminalTabs.all() + if (sessions.isEmpty()) { publishStoppedState() return } + sessions.forEach { tab -> + closingTerminalIds += tab.id + terminateTerminalTab(tab) + } + } + private fun terminateTerminalTab(tab: TerminalTab) { + val session = tab.value + if (!session.isRunning || session.pid < 1) { + handleSessionFinished(session) + return + } try { Os.kill(session.pid, OsConstants.SIGTERM) } catch (error: ErrnoException) { @@ -1150,20 +1373,21 @@ class RuntimeSupervisorService : Service() { component = "supervisor", severity = "warning", event = "terminal_sigterm_failed", - message = error.message ?: "Could not signal the terminal", - bootId = current.bootId, + message = error.message ?: "Could not signal ${tab.title}", + bootId = app.runtimeState.current().bootId, + fields = mapOf("tab_id" to tab.id, "child_pid" to session.pid), ) } mainHandler.postDelayed( { - if (ownedSession.get() === session && session.isRunning) { + if (terminalTabs.get(tab.id)?.value === session && session.isRunning) { app.journal.append( component = "supervisor", severity = "warning", event = "terminal_force_stop", - message = "Terminal ignored SIGTERM; forcing the owned session to stop", - bootId = current.bootId, - fields = mapOf("child_pid" to session.pid), + message = "${tab.title} ignored SIGTERM; forcing it to stop", + bootId = app.runtimeState.current().bootId, + fields = mapOf("tab_id" to tab.id, "child_pid" to session.pid), ) session.finishIfRunning() } @@ -1173,6 +1397,8 @@ class RuntimeSupervisorService : Service() { } private fun publishStoppedState() { + terminalTabs.clear() + closingTerminalIds.clear() val stopped = app.runtimeState.update { it.copy( @@ -1429,6 +1655,7 @@ class RuntimeSupervisorService : Service() { private const val MAX_APP_OUTPUT_CHARS = 2_000 private const val MAX_APP_OUTPUT_LINES = 200 private const val MAX_DESKTOP_OUTPUT_LINES = 400 + private const val MAX_TERMINAL_TITLE_CHARS = 40 private const val DISPLAY_NUMBER = 0 fun start( diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/TerminalTabs.kt b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalTabs.kt new file mode 100644 index 0000000..054847c --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalTabs.kt @@ -0,0 +1,88 @@ +package org.randomcoder.udroid.runtime + +data class TerminalTabSnapshot( + val id: String, + val title: String, + val rootfsName: String, + val pid: Long?, + val running: Boolean, + val active: Boolean, +) + +fun terminalDistroTitle(rawName: String?): String = + when { + rawName.isNullOrBlank() -> "Linux" + rawName.contains("focal", ignoreCase = true) -> "Ubuntu Focal" + rawName.contains("jammy", ignoreCase = true) -> "Ubuntu Jammy" + rawName.contains("noble", ignoreCase = true) -> "Ubuntu Noble" + rawName.contains("resolute", ignoreCase = true) -> "Ubuntu Resolute" + else -> + rawName + .removePrefix("udroid-") + .removeSuffix("-raw") + .split('-') + .joinToString(" ") { word -> word.replaceFirstChar(Char::titlecase) } + } + +internal data class TerminalTab( + val id: String, + var title: String, + val rootfsName: String, + val value: T, +) + +internal class TerminalTabRegistry { + private val tabs = LinkedHashMap>() + + var activeId: String? = null + private set + + fun all(): List> = tabs.values.toList() + + fun active(): TerminalTab? = activeId?.let(tabs::get) + + fun get(id: String): TerminalTab? = tabs[id] + + fun findByValue(value: T): TerminalTab? = + tabs.values.firstOrNull { it.value === value } + + fun add(tab: TerminalTab): TerminalTab { + require(tab.id !in tabs) { "Duplicate terminal tab id ${tab.id}" } + tabs[tab.id] = tab + activeId = tab.id + return tab + } + + fun select(id: String): TerminalTab? { + val tab = tabs[id] ?: return null + activeId = id + return tab + } + + fun rename( + id: String, + title: String, + ): TerminalTab? { + val tab = tabs[id] ?: return null + tab.title = title + return tab + } + + fun remove(id: String): TerminalTab? { + val index = tabs.keys.indexOf(id) + val removed = tabs.remove(id) ?: return null + if (activeId == id) { + val remaining = tabs.values.toList() + activeId = remaining.getOrNull(index.coerceAtMost(remaining.lastIndex))?.id + } + return removed + } + + fun clear(): List> = + tabs.values.toList().also { + tabs.clear() + activeId = null + } + + fun size(): Int = tabs.size +} diff --git a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt index b1bf270..d4ceae8 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt @@ -261,6 +261,7 @@ fun UdroidApp( .windowInsetsPadding(WindowInsets.safeDrawing), snapshot = snapshot, service = runtimeService, + installedRootfses = installedRootfses, onStart = onStart, onStop = onStop, onExit = { onDestinationSelected(UdroidDestination.SYSTEM) }, diff --git a/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt index 6c6e3d2..062a697 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt @@ -2,11 +2,13 @@ package org.randomcoder.udroid.ui import android.content.Context import android.graphics.Typeface +import android.os.Build import android.util.Log import android.view.KeyEvent import android.view.MotionEvent import android.view.inputmethod.InputMethodManager import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -18,42 +20,65 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.StopCircle +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import com.termux.terminal.TerminalSession import com.termux.view.TerminalView import com.termux.view.TerminalViewClient +import org.randomcoder.udroid.catalog.LinuxDistribution +import org.randomcoder.udroid.runtime.InstalledRootfs import org.randomcoder.udroid.runtime.RuntimePhase import org.randomcoder.udroid.runtime.RuntimeSnapshot import org.randomcoder.udroid.runtime.RuntimeSupervisorService +import org.randomcoder.udroid.runtime.TerminalTabSnapshot +import org.randomcoder.udroid.runtime.terminalDistroTitle import kotlin.math.roundToInt @Composable @@ -61,13 +86,37 @@ fun InteractiveTerminalPage( modifier: Modifier = Modifier, snapshot: RuntimeSnapshot, service: RuntimeSupervisorService?, + installedRootfses: List, onStart: () -> Unit, onStop: () -> Unit, onExit: () -> Unit, ) { + val tabs = service?.terminalTabSnapshots().orEmpty() + val activeTab = tabs.firstOrNull(TerminalTabSnapshot::active) val session = service?.currentTerminalSession() var stopRequested by remember(session) { mutableStateOf(false) } + var tabError by remember { mutableStateOf(null) } + var renamingTab by remember { mutableStateOf(null) } + var creatingTab by remember { mutableStateOf(false) } + var showDistroPicker by remember { mutableStateOf(false) } val stopping = stopRequested || snapshot.phase == RuntimePhase.STOPPING + + fun createTab(rootfsName: String?) { + val connectedService = service + when { + connectedService == null -> tabError = "Linux service is reconnecting" + rootfsName.isNullOrBlank() -> tabError = "Select a running terminal first" + else -> { + creatingTab = true + tabError = null + connectedService.createTerminalTab(rootfsName) { result -> + creatingTab = false + tabError = result.exceptionOrNull()?.message + } + } + } + } + Column( modifier = modifier @@ -77,14 +126,41 @@ fun InteractiveTerminalPage( TerminalSessionBar( snapshot = snapshot, session = session, + activeTab = activeTab, stopping = stopping, onExit = onExit, + onChooseDistro = { showDistroPicker = true }, onStop = { stopRequested = true onStop() }, ) + if (tabs.isNotEmpty()) { + TerminalTabsRow( + tabs = tabs, + onSelect = { id -> service?.selectTerminalTab(id) }, + onRename = { tab -> renamingTab = tab }, + onClose = { id -> service?.closeTerminalTab(id) }, + creatingTab = creatingTab, + onClone = { createTab(activeTab?.rootfsName) }, + ) + } + + tabError?.let { message -> + Text( + text = message, + modifier = + Modifier + .fillMaxWidth() + .background(UdroidTerminalSurface) + .padding(horizontal = 14.dp, vertical = 6.dp), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelSmall, + maxLines = 2, + ) + } + if (stopping) { StoppingTerminalState( modifier = Modifier.weight(1f), @@ -104,14 +180,43 @@ fun InteractiveTerminalPage( ) } } + + renamingTab?.let { tab -> + RenameTerminalDialog( + tab = tab, + onDismiss = { renamingTab = null }, + onRename = { title -> + if (service?.renameTerminalTab(tab.id, title) == true) { + renamingTab = null + tabError = null + } else { + tabError = "Enter a terminal name" + } + }, + ) + } + + if (showDistroPicker) { + TerminalDistroPicker( + installedRootfses = installedRootfses, + activeRootfsName = activeTab?.rootfsName, + onDismiss = { showDistroPicker = false }, + onSelect = { rootfs -> + showDistroPicker = false + createTab(rootfs.name) + }, + ) + } } @Composable private fun TerminalSessionBar( snapshot: RuntimeSnapshot, session: TerminalSession?, + activeTab: TerminalTabSnapshot?, stopping: Boolean, onExit: () -> Unit, + onChooseDistro: () -> Unit, onStop: () -> Unit, ) { val running = session?.isRunning == true && !stopping @@ -135,9 +240,10 @@ private fun TerminalSessionBar( modifier = Modifier .weight(1f) - .height(46.dp), + .height(46.dp) + .clickable(enabled = running, onClick = onChooseDistro), color = UdroidTerminalRaised, - shape = RoundedCornerShape(topStart = 9.dp, topEnd = 9.dp), + shape = RoundedCornerShape(9.dp), border = BorderStroke(1.dp, UdroidTerminalLine), ) { Row( @@ -153,7 +259,7 @@ private fun TerminalSessionBar( Spacer(Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { Text( - terminalSessionTitle(session?.mSessionName), + terminalDistroTitle(activeTab?.rootfsName ?: session?.mSessionName), color = UdroidTerminalText, style = MaterialTheme.typography.titleMedium, maxLines = 1, @@ -161,7 +267,9 @@ private fun TerminalSessionBar( Text( when { stopping -> "Stopping terminal…" - running -> "root • aarch64 • PID ${session?.pid}" + running -> + "root • ${Build.SUPPORTED_ABIS.firstOrNull().orEmpty()}" + + " • PID ${session?.pid}" else -> snapshot.message }, color = UdroidTerminalMuted, @@ -169,6 +277,14 @@ private fun TerminalSessionBar( maxLines = 1, ) } + if (running) { + Icon( + imageVector = Icons.Outlined.KeyboardArrowDown, + contentDescription = "Choose installed Linux system", + modifier = Modifier.size(20.dp), + tint = UdroidTerminalMuted, + ) + } } } if (stopping) { @@ -196,6 +312,303 @@ private fun TerminalSessionBar( } } +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun TerminalTabsRow( + tabs: List, + onSelect: (String) -> Unit, + onRename: (TerminalTabSnapshot) -> Unit, + onClose: (String) -> Unit, + creatingTab: Boolean, + onClone: () -> Unit, +) { + val activeTabId = tabs.firstOrNull(TerminalTabSnapshot::active)?.id + val activeTabRequester = remember(activeTabId) { BringIntoViewRequester() } + val tabsScrollState = rememberScrollState() + LaunchedEffect(activeTabId, tabs.size) { + if (activeTabId != null) { + withFrameNanos { } + activeTabRequester.bringIntoView() + } + } + Row( + modifier = + Modifier + .fillMaxWidth() + .height(52.dp) + .background(UdroidTerminalSurface), + verticalAlignment = Alignment.Bottom, + ) { + Row( + modifier = + Modifier + .weight(1f) + .height(52.dp) + .horizontalScroll(tabsScrollState) + .padding(start = 8.dp, top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Bottom, + ) { + tabs.forEach { tab -> + TerminalTabChip( + modifier = + if (tab.id == activeTabId) { + Modifier.bringIntoViewRequester(activeTabRequester) + } else { + Modifier + }, + tab = tab, + onSelect = { onSelect(tab.id) }, + onRename = { onRename(tab) }, + onClose = { onClose(tab.id) }, + ) + } + } + Spacer(Modifier.width(6.dp)) + Surface( + modifier = + Modifier + .width(54.dp) + .height(46.dp) + .clickable(enabled = !creatingTab, onClick = onClone), + color = UdroidTerminalRaised, + shape = RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp), + ) { + Box(contentAlignment = Alignment.Center) { + if (creatingTab) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = UdroidTerminalGreen, + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = "Clone current Linux terminal", + modifier = Modifier.size(28.dp), + tint = UdroidTerminalText, + ) + } + } + } + Spacer(Modifier.width(8.dp)) + } +} + +@Composable +private fun TerminalTabChip( + modifier: Modifier = Modifier, + tab: TerminalTabSnapshot, + onSelect: () -> Unit, + onRename: () -> Unit, + onClose: () -> Unit, +) { + var menuExpanded by remember { mutableStateOf(false) } + Surface( + modifier = + modifier + .width(144.dp) + .height(46.dp) + .clickable(onClick = onSelect), + color = if (tab.active) UdroidTerminal else UdroidTerminalRaised, + shape = RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp), + border = + if (tab.active) { + null + } else { + BorderStroke(1.dp, UdroidTerminalLine) + }, + ) { + Row( + modifier = Modifier.padding(start = 10.dp, end = 1.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Terminal, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (tab.active) UdroidTerminalGreen else UdroidTerminalMuted, + ) + Spacer(Modifier.width(7.dp)) + Text( + text = tab.title, + modifier = Modifier.weight(1f), + color = if (tab.active) UdroidTerminalText else UdroidTerminalMuted, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Box { + IconButton( + modifier = Modifier.size(36.dp), + onClick = { menuExpanded = true }, + ) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = "${tab.title} settings", + modifier = Modifier.size(18.dp), + tint = UdroidTerminalMuted, + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("Rename") }, + onClick = { + menuExpanded = false + onRename() + }, + ) + DropdownMenuItem( + text = { Text("Close terminal") }, + leadingIcon = { + Icon(Icons.Outlined.Close, contentDescription = null) + }, + onClick = { + menuExpanded = false + onClose() + }, + ) + } + } + } + } +} + +@Composable +private fun TerminalDistroPicker( + installedRootfses: List, + activeRootfsName: String?, + onDismiss: () -> Unit, + onSelect: (InstalledRootfs) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Open a Linux terminal") }, + text = { + if (installedRootfses.isEmpty()) { + Text("Install a Linux system before opening another terminal.") + } else { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 380.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = installedRootfses, + key = InstalledRootfs::name, + ) { rootfs -> + val distribution = terminalDistribution(rootfs.name) + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable { onSelect(rootfs) }, + color = UdroidTerminalRaised, + shape = RoundedCornerShape(11.dp), + border = BorderStroke(1.dp, UdroidTerminalLine), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (distribution != null) { + DistroMark( + distribution = distribution, + size = 38, + ) + } else { + Surface( + modifier = Modifier.size(38.dp), + color = UdroidTerminal, + shape = RoundedCornerShape(9.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.Terminal, + contentDescription = null, + modifier = Modifier.size(19.dp), + tint = UdroidTerminalGreen, + ) + } + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = terminalDistroTitle(rootfs.name), + color = UdroidTerminalText, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = + if (rootfs.name == activeRootfsName) { + "Current distro · ${rootfs.name}" + } else { + rootfs.name + }, + color = UdroidTerminalMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun RenameTerminalDialog( + tab: TerminalTabSnapshot, + onDismiss: () -> Unit, + onRename: (String) -> Unit, +) { + var title by remember(tab.id) { mutableStateOf(tab.title) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Rename terminal") }, + text = { + OutlinedTextField( + value = title, + onValueChange = { title = it.take(40) }, + label = { Text("Terminal name") }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { onRename(title) }, + enabled = title.isNotBlank(), + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + @Composable private fun StoppingTerminalState( modifier: Modifier = Modifier, @@ -234,19 +647,18 @@ private fun StoppingTerminalState( } } -private fun terminalSessionTitle(rawName: String?): String = - when { - rawName.isNullOrBlank() -> "Linux terminal" - rawName.contains("jammy", ignoreCase = true) -> "Ubuntu Jammy" - rawName.contains("noble", ignoreCase = true) -> "Ubuntu Noble" - rawName.contains("resolute", ignoreCase = true) -> "Ubuntu Resolute" - else -> - rawName - .removePrefix("udroid-") - .removeSuffix("-raw") - .split('-') - .joinToString(" ") { word -> word.replaceFirstChar(Char::titlecase) } +private fun terminalDistribution(rootfsName: String): LinuxDistribution? { + val normalized = rootfsName.lowercase() + return when { + listOf("focal", "jammy", "noble", "resolute", "ubuntu") + .any(normalized::contains) -> LinuxDistribution.UBUNTU + "debian" in normalized -> LinuxDistribution.DEBIAN + "arch" in normalized -> LinuxDistribution.ARCH + "alpine" in normalized -> LinuxDistribution.ALPINE + "void" in normalized -> LinuxDistribution.VOID + else -> null } +} @Composable private fun EmptyTerminalState( @@ -329,7 +741,7 @@ private fun LiveTerminal( with(density) { 15.sp.toPx().roundToInt() } } val client = - remember(session) { + remember { UdroidTerminalViewClient( context = context, modifiers = modifiers, @@ -337,7 +749,7 @@ private fun LiveTerminal( ) } val terminalView = - remember(session) { + remember { TerminalView(context, null).apply { setBackgroundColor(android.graphics.Color.rgb(17, 19, 31)) setTextSize(initialTextSize) @@ -359,6 +771,12 @@ private fun LiveTerminal( Column(modifier = modifier.fillMaxSize()) { AndroidView( factory = { terminalView }, + update = { view -> + if (view.mTermSession !== session) { + view.attachSession(session) + } + view.onScreenUpdated() + }, modifier = Modifier .weight(1f) diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/TerminalTabRegistryTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalTabRegistryTest.kt new file mode 100644 index 0000000..0ba2812 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalTabRegistryTest.kt @@ -0,0 +1,92 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TerminalTabRegistryTest { + @Test + fun `new tab becomes active and preserves insertion order`() { + val registry = TerminalTabRegistry() + registry.add(tab("one")) + registry.add(tab("two")) + + assertEquals(listOf("one", "two"), registry.all().map { it.id }) + assertEquals("two", registry.activeId) + } + + @Test + fun `removing active tab selects its next neighbour`() { + val registry = TerminalTabRegistry() + registry.add(tab("one")) + registry.add(tab("two")) + registry.add(tab("three")) + registry.select("two") + + registry.remove("two") + + assertEquals("three", registry.activeId) + } + + @Test + fun `removing final active tab selects previous neighbour`() { + val registry = TerminalTabRegistry() + registry.add(tab("one")) + registry.add(tab("two")) + + registry.remove("two") + + assertEquals("one", registry.activeId) + } + + @Test + fun `rename affects only requested tab`() { + val registry = TerminalTabRegistry() + registry.add(tab("one")) + registry.add(tab("two")) + + registry.rename("one", "Logs") + + assertEquals("Logs", registry.get("one")?.title) + assertEquals("Terminal two", registry.get("two")?.title) + } + + @Test + fun `removing last tab clears active id`() { + val registry = TerminalTabRegistry() + registry.add(tab("one")) + + registry.remove("one") + + assertNull(registry.activeId) + } + + @Test + fun `tabs preserve independent rootfs assignments`() { + val registry = TerminalTabRegistry() + registry.add(tab("ubuntu", rootfsName = "udroid-jammy-raw")) + registry.add(tab("debian", rootfsName = "debian-bookworm")) + + registry.select("ubuntu") + + assertEquals("udroid-jammy-raw", registry.active()?.rootfsName) + assertEquals("debian-bookworm", registry.get("debian")?.rootfsName) + } + + @Test + fun `rootfs names become readable distro titles`() { + assertEquals("Ubuntu Jammy", terminalDistroTitle("udroid-jammy-raw")) + assertEquals("Debian Bookworm", terminalDistroTitle("debian-bookworm")) + } + + private fun tab( + id: String, + rootfsName: String = "ubuntu", + ) = + TerminalTab( + id = id, + title = "Terminal $id", + rootfsName = rootfsName, + value = Any(), + ) +}