From a3baabfbb93de0325a605934d4467a32fdb4866b Mon Sep 17 00:00:00 2001 From: Mohit Kota Date: Sat, 7 Nov 2026 00:07:44 +0530 Subject: [PATCH] feat(shortcuts): add terminal and display home actions --- .../org/randomcoder/udroid/MainActivity.kt | 119 +++++++++++++++++- .../udroid/runtime/RootfsShortcutPublisher.kt | 103 +++++++++++++++ .../randomcoder/udroid/ui/LinuxSystemPage.kt | 43 +++++++ .../org/randomcoder/udroid/ui/TerminalPage.kt | 30 +++++ .../runtime/RootfsShortcutContractTest.kt | 57 +++++++++ 5 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/org/randomcoder/udroid/runtime/RootfsShortcutPublisher.kt create mode 100644 app/src/test/java/org/randomcoder/udroid/runtime/RootfsShortcutContractTest.kt diff --git a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt index 0b0e349..00f135c 100644 --- a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt +++ b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt @@ -66,6 +66,9 @@ import org.randomcoder.udroid.runtime.ProotMountResolver import org.randomcoder.udroid.runtime.RuntimePhase import org.randomcoder.udroid.runtime.RuntimeSnapshot import org.randomcoder.udroid.runtime.RuntimeSupervisorService +import org.randomcoder.udroid.runtime.RootfsShortcutContract +import org.randomcoder.udroid.runtime.RootfsShortcutPublisher +import org.randomcoder.udroid.runtime.RootfsShortcutTarget import org.randomcoder.udroid.runtime.RootfsStorageUsage import org.randomcoder.udroid.runtime.VirglServerMode import org.randomcoder.udroid.ui.UdroidApp @@ -95,6 +98,10 @@ private data class PendingDesktopStart( val configuration: DesktopConfiguration, ) +private data class PendingTerminalShortcut( + val tabId: String, + val rootfsName: String, +) class MainActivity : ComponentActivity() { private val app: UdroidApplication get() = application as UdroidApplication @@ -132,11 +139,13 @@ class MainActivity : ComponentActivity() { private var pendingLinuxApplication: PendingLinuxApplication? = null private var pendingLinuxApplicationId: String? = null private var pendingDesktopStart: PendingDesktopStart? = null + private var pendingDesktopShortcutRootfsName: String? = null private var pendingShortcutRootfsName: String? = null private var pendingTerminalRootfsName: String? = null private var pendingDebugDesktop: DebugCommand? = null private var pendingDebugStopDesktop = false private var pendingMicrophoneRootfsName: String? = null + private var pendingTerminalShortcut: PendingTerminalShortcut? = null private var linuxApplicationsLoadGeneration = 0L private var desktopScanGeneration = 0L private var ociTagsLoadGeneration = 0L @@ -324,7 +333,9 @@ class MainActivity : ComponentActivity() { loadCatalogue() handleUpdateIntent(intent) handleInstallerIntent(intent) - if (!handleShortcutIntent(intent)) loadLinuxApplications() + if (!handleRootfsShortcutIntent(intent) && !handleShortcutIntent(intent)) { + loadLinuxApplications() + } handleDebugCommand() } @@ -333,7 +344,7 @@ class MainActivity : ComponentActivity() { setIntent(intent) handleUpdateIntent(intent) handleInstallerIntent(intent) - handleShortcutIntent(intent) + if (!handleRootfsShortcutIntent(intent)) handleShortcutIntent(intent) handleDebugCommand() } @@ -378,6 +389,12 @@ class MainActivity : ComponentActivity() { ).destination selectedDestination = resolvedDestination applySystemBars(resolvedDestination) + if (resolvedDestination != UdroidDestination.SYSTEM) { + pendingDesktopShortcutRootfsName = null + } + if (resolvedDestination != UdroidDestination.TERMINAL) { + pendingTerminalShortcut = null + } if (resolvedDestination == UdroidDestination.APPS) loadLinuxApplications() if (resolvedDestination == UdroidDestination.SYSTEM) { selectedSystemRootfsName?.let(::loadDesktopEnvironments) @@ -481,6 +498,7 @@ class MainActivity : ComponentActivity() { } launchPendingLinuxApplication() launchPendingDesktop() + selectPendingTerminalShortcut() } private fun loadCatalogue() { @@ -866,6 +884,10 @@ class MainActivity : ComponentActivity() { .disableForRootfs(rootfsName) }.exceptionOrNull() ?.let { cleanupWarnings += it.message ?: "launcher shortcuts" } + runCatching { + RootfsShortcutPublisher(this@MainActivity).disableForRootfs(rootfsName) + }.exceptionOrNull() + ?.let { cleanupWarnings += it.message ?: "terminal and display shortcuts" } if (resetWork == null) { runCatching { ProotEnvironmentProfileStore(this@MainActivity).remove(rootfsName) } @@ -907,7 +929,11 @@ class MainActivity : ComponentActivity() { pendingLinuxApplication = null pendingDesktopStart = null + pendingDesktopShortcutRootfsName = null pendingTerminalRootfsName = null + if (pendingTerminalShortcut?.rootfsName == rootfsName) { + pendingTerminalShortcut = null + } linuxApplicationsLoadGeneration++ desktopScanGeneration++ rootfsMaintenanceName = null @@ -988,6 +1014,7 @@ class MainActivity : ComponentActivity() { if (environments.size == 1) " detected" else "s detected" } launchPendingDebugDesktop(rootfsName) + launchPendingDesktopShortcut(rootfsName) }, onFailure = { desktopEnvironments = emptyList() @@ -1002,6 +1029,9 @@ class MainActivity : ComponentActivity() { debugCommandError(desktopScanMessage ?: "Desktop detection failed") } pendingDebugDesktop = null + if (pendingDesktopShortcutRootfsName == rootfsName) { + pendingDesktopShortcutRootfsName = null + } }, ) } @@ -1060,6 +1090,14 @@ class MainActivity : ComponentActivity() { selectDestination(UdroidDestination.DESKTOP) } + private fun launchPendingDesktopShortcut(rootfsName: String) { + if (pendingDesktopShortcutRootfsName != rootfsName) return + pendingDesktopShortcutRootfsName = null + if (desktopEnvironments.isEmpty()) return + startSelectedDesktop() + selectDestination(UdroidDestination.DESKTOP) + } + private fun debugCommandError(message: String) { app.journal.append( component = "debug", @@ -1354,6 +1392,46 @@ class MainActivity : ComponentActivity() { return true } + private fun handleRootfsShortcutIntent(intent: Intent?): Boolean { + if (intent?.action != RootfsShortcutContract.ACTION_OPEN) return false + val rootfsName = + intent + .getStringExtra(RootfsShortcutContract.EXTRA_ROOTFS_NAME) + ?.takeIf(String::isNotBlank) + val target = + intent + .getStringExtra(RootfsShortcutContract.EXTRA_TARGET) + ?.let { runCatching { RootfsShortcutTarget.valueOf(it) }.getOrNull() } + val terminalTabId = + intent + .getStringExtra(RootfsShortcutContract.EXTRA_TERMINAL_TAB_ID) + ?.takeIf(String::isNotBlank) + intent.removeExtra(RootfsShortcutContract.EXTRA_ROOTFS_NAME) + intent.removeExtra(RootfsShortcutContract.EXTRA_TARGET) + intent.removeExtra(RootfsShortcutContract.EXTRA_TERMINAL_TAB_ID) + if (rootfsName == null || target == null) return true + if (installedRootfses.none { it.name == rootfsName }) { + rootfsMaintenanceMessage = "The Linux system for this shortcut is no longer installed" + selectDestination(UdroidDestination.DISTROS) + return true + } + when (target) { + RootfsShortcutTarget.TERMINAL -> openRootfsTerminal(rootfsName, terminalTabId) + RootfsShortcutTarget.DISPLAY -> { + if ( + snapshot.desktop.rootfsName == rootfsName && + snapshot.desktop.phase == DesktopSessionPhase.RUNNING + ) { + selectDestination(UdroidDestination.DESKTOP) + } else { + pendingDesktopShortcutRootfsName = rootfsName + openSystemDetails(rootfsName) + } + } + } + return true + } + private fun resolveShortcutApplication(state: LinuxApplicationsState) { val applicationId = pendingLinuxApplicationId ?: return when (state) { @@ -1440,9 +1518,27 @@ class MainActivity : ComponentActivity() { selectDestination(UdroidDestination.APPS) } - private fun openRootfsTerminal(rootfsName: String) { + private fun openRootfsTerminal( + rootfsName: String, + terminalTabId: String? = null, + ) { setActiveRootfs(rootfsName) ensureNotificationPermission() + pendingTerminalShortcut = + terminalTabId?.let { PendingTerminalShortcut(it, rootfsName) } + val requestedTab = + runtimeService + ?.terminalTabSnapshots() + ?.firstOrNull { + it.rootfsName == rootfsName && + (terminalTabId == null || it.id == terminalTabId) + } + if (requestedTab != null && runtimeService?.selectTerminalTab(requestedTab.id) == true) { + pendingTerminalShortcut = null + pendingTerminalRootfsName = null + selectDestination(UdroidDestination.TERMINAL) + return + } val runningSession = runtimeService?.currentTerminalSession() if (runningSession?.isRunning == true && runningSession.mSessionName != rootfsName) { pendingTerminalRootfsName = rootfsName @@ -1454,6 +1550,23 @@ class MainActivity : ComponentActivity() { selectDestination(UdroidDestination.TERMINAL) } + private fun selectPendingTerminalShortcut() { + val pending = pendingTerminalShortcut ?: return + val service = runtimeService ?: return + val requestedTab = + service + .terminalTabSnapshots() + .firstOrNull { it.id == pending.tabId && it.rootfsName == pending.rootfsName } + if (requestedTab != null) { + service.selectTerminalTab(requestedTab.id) + pendingTerminalShortcut = null + } else if ( + snapshot.phase == RuntimePhase.RUNNING && + service.currentTerminalSession()?.isRunning == true + ) { + pendingTerminalShortcut = null + } + } private fun ensureNotificationPermission() { if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/RootfsShortcutPublisher.kt b/app/src/main/java/org/randomcoder/udroid/runtime/RootfsShortcutPublisher.kt new file mode 100644 index 0000000..261dbda --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/runtime/RootfsShortcutPublisher.kt @@ -0,0 +1,103 @@ +package org.randomcoder.udroid.runtime + +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutManager +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import org.randomcoder.udroid.MainActivity +import org.randomcoder.udroid.R + +enum class RootfsShortcutTarget { + TERMINAL, + DISPLAY, +} + +class RootfsShortcutPublisher( + private val context: Context, +) { + fun publishAndRequestPin( + rootfsName: String, + label: String, + target: RootfsShortcutTarget, + terminalTabId: String? = null, + ): Result = + runCatching { + require(label.isNotBlank()) { "Shortcut name must not be blank" } + check(ShortcutManagerCompat.isRequestPinShortcutSupported(context)) { + "This launcher does not support adding shortcuts to the home screen" + } + val shortcutIntent = + Intent(context, MainActivity::class.java) + .setAction(RootfsShortcutContract.ACTION_OPEN) + .putExtra(RootfsShortcutContract.EXTRA_ROOTFS_NAME, rootfsName) + .putExtra(RootfsShortcutContract.EXTRA_TARGET, target.name) + .addFlags( + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP, + ).apply { + terminalTabId?.let { + putExtra(RootfsShortcutContract.EXTRA_TERMINAL_TAB_ID, it) + } + } + val shortcut = + ShortcutInfoCompat + .Builder( + context, + RootfsShortcutContract.shortcutId(rootfsName, target, terminalTabId), + ).setShortLabel(label.trim()) + .setLongLabel("Open ${label.trim()} in uDroid") + .setIcon(IconCompat.createWithResource(context, R.drawable.udroid_logo)) + .setIntent(shortcutIntent) + .build() + check(ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)) { + "The launcher did not accept the shortcut request" + } + } + + fun disableForRootfs(rootfsName: String) { + val manager = context.getSystemService(ShortcutManager::class.java) ?: return + val shortcutIds = + manager.pinnedShortcuts + .asSequence() + .map { it.id } + .filter { RootfsShortcutContract.belongsToRootfs(it, rootfsName) } + .toList() + if (shortcutIds.isEmpty()) return + manager.disableShortcuts( + shortcutIds, + "The Linux system for this shortcut was removed", + ) + } +} + +object RootfsShortcutContract { + const val ACTION_OPEN = "org.randomcoder.udroid.action.OPEN_ROOTFS_SHORTCUT" + const val EXTRA_ROOTFS_NAME = "org.randomcoder.udroid.extra.ROOTFS_NAME" + const val EXTRA_TARGET = "org.randomcoder.udroid.extra.ROOTFS_SHORTCUT_TARGET" + const val EXTRA_TERMINAL_TAB_ID = "org.randomcoder.udroid.extra.TERMINAL_TAB_ID" + + private const val SHORTCUT_ID_PREFIX = "rootfs:" + + fun belongsToRootfs( + shortcutId: String, + rootfsName: String, + ): Boolean = shortcutId.startsWith("$SHORTCUT_ID_PREFIX$rootfsName:") + + fun shortcutId( + rootfsName: String, + target: RootfsShortcutTarget, + terminalTabId: String? = null, + ): String { + require(rootfsName.isNotBlank()) { "Linux system name must not be blank" } + require(terminalTabId == null || terminalTabId.isNotBlank()) { + "Terminal tab ID must not be blank" + } + require(target == RootfsShortcutTarget.TERMINAL || terminalTabId == null) { + "Only terminal shortcuts can target a terminal tab" + } + return listOfNotNull(SHORTCUT_ID_PREFIX + rootfsName, target.name.lowercase(), terminalTabId) + .joinToString(":") + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt index ba0da2e..16a16ff 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt @@ -1,5 +1,6 @@ package org.randomcoder.udroid.ui +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable @@ -77,6 +78,8 @@ import org.randomcoder.udroid.runtime.ProotMountProfile import org.randomcoder.udroid.runtime.ProotMountProfileStore import org.randomcoder.udroid.runtime.RuntimePhase import org.randomcoder.udroid.runtime.RuntimeSnapshot +import org.randomcoder.udroid.runtime.RootfsShortcutPublisher +import org.randomcoder.udroid.runtime.RootfsShortcutTarget import org.randomcoder.udroid.runtime.VirglServerMode import java.text.DateFormat import java.util.Date @@ -143,6 +146,20 @@ fun LinuxSystemPage( val selectedEnvironment = environments.firstOrNull { it.id == configuration.environmentId } ?: environments.firstOrNull() + val systemLabel = distro?.releaseName ?: systemTitle(rootfs.name) + fun pinShortcut(target: RootfsShortcutTarget) { + val destination = + if (target == RootfsShortcutTarget.TERMINAL) "Terminal" else "Display" + RootfsShortcutPublisher(context) + .publishAndRequestPin(rootfs.name, "$systemLabel $destination", target) + .onFailure { + Toast.makeText( + context, + it.message ?: "Could not add the shortcut", + Toast.LENGTH_LONG, + ).show() + } + } val desktop = snapshot.desktop val runtimeOwnsSystem = snapshot.rootfsName == rootfs.name && @@ -264,6 +281,32 @@ fun LinuxSystemPage( } } + item(key = "home-shortcuts-label") { + UdroidSectionLabel("Home screen") + } + item(key = "home-shortcuts") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { pinShortcut(RootfsShortcutTarget.TERMINAL) }, + ) { + Icon(Icons.Rounded.Terminal, contentDescription = null) + Text("Add terminal", modifier = Modifier.padding(start = 6.dp)) + } + OutlinedButton( + modifier = Modifier.weight(1f), + enabled = selectedEnvironment != null, + onClick = { pinShortcut(RootfsShortcutTarget.DISPLAY) }, + ) { + Icon(Icons.Rounded.DesktopWindows, contentDescription = null) + Text("Add display", modifier = Modifier.padding(start = 6.dp)) + } + } + } + childProcessRestriction?.let { restriction -> item(key = "child-process-restriction") { Surface( 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 de7f9a6..197575e 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt @@ -77,6 +77,8 @@ 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.RootfsShortcutPublisher +import org.randomcoder.udroid.runtime.RootfsShortcutTarget import org.randomcoder.udroid.runtime.TerminalTabSnapshot import org.randomcoder.udroid.runtime.terminalDistroTitle import kotlin.math.roundToInt @@ -91,6 +93,7 @@ fun InteractiveTerminalPage( onStop: () -> Unit, onExit: () -> Unit, ) { + val context = LocalContext.current val tabs = service?.terminalTabSnapshots().orEmpty() val activeTab = tabs.firstOrNull(TerminalTabSnapshot::active) val session = service?.currentTerminalSession() @@ -141,6 +144,20 @@ fun InteractiveTerminalPage( tabs = tabs, onSelect = { id -> service?.selectTerminalTab(id) }, onRename = { tab -> renamingTab = tab }, + onPin = { tab -> + RootfsShortcutPublisher(context) + .publishAndRequestPin( + rootfsName = tab.rootfsName, + label = tab.title, + target = RootfsShortcutTarget.TERMINAL, + terminalTabId = tab.id, + ).fold( + onSuccess = { tabError = null }, + onFailure = { + tabError = it.message ?: "Could not add the terminal shortcut" + }, + ) + }, onClose = { id -> service?.closeTerminalTab(id) }, creatingTab = creatingTab, onClone = { createTab(activeTab?.rootfsName) }, @@ -318,6 +335,7 @@ private fun TerminalTabsRow( tabs: List, onSelect: (String) -> Unit, onRename: (TerminalTabSnapshot) -> Unit, + onPin: (TerminalTabSnapshot) -> Unit, onClose: (String) -> Unit, creatingTab: Boolean, onClone: () -> Unit, @@ -360,6 +378,7 @@ private fun TerminalTabsRow( tab = tab, onSelect = { onSelect(tab.id) }, onRename = { onRename(tab) }, + onPin = { onPin(tab) }, onClose = { onClose(tab.id) }, ) } @@ -401,6 +420,7 @@ private fun TerminalTabChip( tab: TerminalTabSnapshot, onSelect: () -> Unit, onRename: () -> Unit, + onPin: () -> Unit, onClose: () -> Unit, ) { var menuExpanded by remember { mutableStateOf(false) } @@ -461,6 +481,16 @@ private fun TerminalTabChip( onRename() }, ) + DropdownMenuItem( + text = { Text("Add to Home screen") }, + leadingIcon = { + Icon(Icons.Outlined.Terminal, contentDescription = null) + }, + onClick = { + menuExpanded = false + onPin() + }, + ) DropdownMenuItem( text = { Text("Close terminal") }, leadingIcon = { diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/RootfsShortcutContractTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/RootfsShortcutContractTest.kt new file mode 100644 index 0000000..82a3a40 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/RootfsShortcutContractTest.kt @@ -0,0 +1,57 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class RootfsShortcutContractTest { + @Test + fun derivesStableShortcutIds() { + assertEquals( + "rootfs:udroid-jammy:terminal:tab-2", + RootfsShortcutContract.shortcutId( + "udroid-jammy", + RootfsShortcutTarget.TERMINAL, + "tab-2", + ), + ) + assertEquals( + "rootfs:udroid-jammy:display", + RootfsShortcutContract.shortcutId( + "udroid-jammy", + RootfsShortcutTarget.DISPLAY, + ), + ) + } + + @Test + fun validatesShortcutOwnershipAndTargets() { + val shortcut = + RootfsShortcutContract.shortcutId( + "oci-alpine-3.22", + RootfsShortcutTarget.DISPLAY, + ) + + assertTrue(RootfsShortcutContract.belongsToRootfs(shortcut, "oci-alpine-3.22")) + assertFalse(RootfsShortcutContract.belongsToRootfs(shortcut, "oci-alpine")) + assertThrows(IllegalArgumentException::class.java) { + RootfsShortcutContract.shortcutId(" ", RootfsShortcutTarget.TERMINAL) + } + assertThrows(IllegalArgumentException::class.java) { + RootfsShortcutContract.shortcutId( + "udroid-jammy", + RootfsShortcutTarget.TERMINAL, + " ", + ) + } + assertThrows(IllegalArgumentException::class.java) { + RootfsShortcutContract.shortcutId( + "udroid-jammy", + RootfsShortcutTarget.DISPLAY, + "tab-2", + ) + } + } +}