diff --git a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt index 0b0e349..617de93 100644 --- a/app/src/main/java/org/randomcoder/udroid/MainActivity.kt +++ b/app/src/main/java/org/randomcoder/udroid/MainActivity.kt @@ -12,6 +12,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.IBinder +import android.provider.OpenableColumns import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent @@ -67,6 +68,8 @@ import org.randomcoder.udroid.runtime.RuntimePhase import org.randomcoder.udroid.runtime.RuntimeSnapshot import org.randomcoder.udroid.runtime.RuntimeSupervisorService import org.randomcoder.udroid.runtime.RootfsStorageUsage +import org.randomcoder.udroid.runtime.TerminalFileImporter +import org.randomcoder.udroid.runtime.TerminalTabSnapshot import org.randomcoder.udroid.runtime.VirglServerMode import org.randomcoder.udroid.ui.UdroidApp import org.randomcoder.udroid.ui.UdroidCanvas @@ -95,6 +98,11 @@ private data class PendingDesktopStart( val configuration: DesktopConfiguration, ) +private data class PendingTerminalUpload( + val tabId: String, + val rootfsName: String, +) + class MainActivity : ComponentActivity() { private val app: UdroidApplication get() = application as UdroidApplication @@ -129,6 +137,9 @@ class MainActivity : ComponentActivity() { private var linuxApplicationsState by mutableStateOf(LinuxApplicationsState.Loading) private var linuxApplicationMessage by mutableStateOf(null) + private var terminalUploadTabId by mutableStateOf(null) + private var terminalUploadMessage by mutableStateOf(null) + private var terminalUploadFailed by mutableStateOf(false) private var pendingLinuxApplication: PendingLinuxApplication? = null private var pendingLinuxApplicationId: String? = null private var pendingDesktopStart: PendingDesktopStart? = null @@ -137,6 +148,7 @@ class MainActivity : ComponentActivity() { private var pendingDebugDesktop: DebugCommand? = null private var pendingDebugStopDesktop = false private var pendingMicrophoneRootfsName: String? = null + private var pendingTerminalUpload: PendingTerminalUpload? = null private var linuxApplicationsLoadGeneration = 0L private var desktopScanGeneration = 0L private var ociTagsLoadGeneration = 0L @@ -171,6 +183,49 @@ class MainActivity : ComponentActivity() { } } + private val terminalFilePicker = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + val target = pendingTerminalUpload + pendingTerminalUpload = null + if (uri == null || target == null) return@registerForActivityResult + + terminalUploadTabId = target.tabId + terminalUploadMessage = null + lifecycleScope.launch { + val result = + withContext(Dispatchers.IO) { + runCatching { + val rootfs = + app.rootfsRegistry + .all() + .firstOrNull { it.name == target.rootfsName } + ?.directory + ?: error( + "Linux system ${target.rootfsName} is not installed or ready", + ) + val sourceName = terminalUploadDisplayName(uri) + val source = + contentResolver.openInputStream(uri) + ?: error("The selected file could not be opened") + source.use { + TerminalFileImporter.import(rootfs, sourceName, it) + } + } + } + terminalUploadTabId = null + result.fold( + onSuccess = { + terminalUploadFailed = false + terminalUploadMessage = "Uploaded to $it" + }, + onFailure = { + terminalUploadFailed = true + terminalUploadMessage = it.message ?: "The selected file could not be uploaded" + }, + ) + } + } + private val runtimeServiceConnection = object : ServiceConnection { override fun onServiceConnected( @@ -244,6 +299,9 @@ class MainActivity : ComponentActivity() { linuxApplicationMessage = linuxApplicationMessage, showInstallTerminal = showInstallTerminal, runtimeService = runtimeService, + terminalUploadTabId = terminalUploadTabId, + terminalUploadMessage = terminalUploadMessage, + terminalUploadFailed = terminalUploadFailed, onDestinationSelected = ::selectDestination, onPrimaryDestinationSelected = ::selectPrimaryDestination, onStart = { @@ -252,6 +310,7 @@ class MainActivity : ComponentActivity() { selectDestination(UdroidDestination.TERMINAL) }, onStop = { RuntimeSupervisorService.stop(this) }, + onUploadTerminalFile = ::uploadTerminalFile, onRefresh = { refreshAll() }, onReloadCatalogue = { loadCatalogue() }, onPreviewInstall = { selectDistro(it) }, @@ -1454,6 +1513,31 @@ class MainActivity : ComponentActivity() { selectDestination(UdroidDestination.TERMINAL) } + private fun uploadTerminalFile(tab: TerminalTabSnapshot) { + if (pendingTerminalUpload != null || terminalUploadTabId != null) { + terminalUploadFailed = true + terminalUploadMessage = "Wait for the current upload to finish" + return + } + pendingTerminalUpload = PendingTerminalUpload(tab.id, tab.rootfsName) + terminalUploadMessage = null + terminalFilePicker.launch(arrayOf("*/*")) + } + + private fun terminalUploadDisplayName(uri: Uri): String? = + runCatching { + contentResolver + .query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + }.getOrNull() ?: uri.lastPathSegment + 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/TerminalFileImporter.kt b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalFileImporter.kt new file mode 100644 index 0000000..a3b3909 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalFileImporter.kt @@ -0,0 +1,86 @@ +package org.randomcoder.udroid.runtime + +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.PosixFilePermissions +import java.util.UUID + +internal object TerminalFileImporter { + fun import( + rootfs: File, + sourceName: String?, + source: InputStream, + ): String { + val root = rootfs.toPath().toAbsolutePath().normalize() + require(Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + "The selected Linux system is unavailable" + } + + val rootHome = root.resolve("root") + check(!Files.isSymbolicLink(rootHome)) { + "The Linux home directory cannot be a symbolic link" + } + val home = if (Files.isDirectory(rootHome, LinkOption.NOFOLLOW_LINKS)) rootHome else root + val uploads = home.resolve("Uploads") + if (Files.exists(uploads, LinkOption.NOFOLLOW_LINKS)) { + check(Files.isDirectory(uploads, LinkOption.NOFOLLOW_LINKS)) { + "~/Uploads is not a directory" + } + } else { + Files.createDirectory(uploads) + } + + val fileName = safeFileName(sourceName) + val destination = uploads.resolve(fileName) + check(!Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + "~/Uploads/$fileName already exists" + } + + val staging = uploads.resolve(".upload-${UUID.randomUUID()}.part") + try { + Files.createFile( + staging, + PosixFilePermissions.asFileAttribute( + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ), + ), + ) + FileOutputStream(staging.toFile()).use { output -> + source.copyTo(output) + output.fd.sync() + } + try { + Files.move(staging, destination, StandardCopyOption.ATOMIC_MOVE) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(staging, destination) + } + } finally { + Files.deleteIfExists(staging) + } + return "~/Uploads/$fileName" + } + + internal fun safeFileName(sourceName: String?): String { + val sanitized = + sourceName + .orEmpty() + .substringAfterLast('/') + .substringAfterLast('\\') + .map { character -> + if (character.code < 32 || character.code == 127) '_' else character + }.joinToString("") + .trim() + .take(MAX_FILE_NAME_CHARS) + return sanitized.takeUnless { it.isBlank() || it == "." || it == ".." } ?: "upload" + } + + private const val MAX_FILE_NAME_CHARS = 64 +} 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 f1c69ff..a10d30c 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt @@ -117,6 +117,7 @@ import org.randomcoder.udroid.runtime.ProotMountProfileStore 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.VirglServerMode import org.randomcoder.udroid.update.AppUpdatePhase import org.randomcoder.udroid.update.AppUpdateState @@ -201,10 +202,14 @@ fun UdroidApp( linuxApplicationMessage: String?, showInstallTerminal: Boolean, runtimeService: RuntimeSupervisorService?, + terminalUploadTabId: String?, + terminalUploadMessage: String?, + terminalUploadFailed: Boolean, onDestinationSelected: (UdroidDestination) -> Unit, onPrimaryDestinationSelected: (UdroidDestination) -> Unit, onStart: () -> Unit, onStop: () -> Unit, + onUploadTerminalFile: (TerminalTabSnapshot) -> Unit, onRefresh: () -> Unit, onReloadCatalogue: () -> Unit, onPreviewInstall: (DistroVariant) -> Unit, @@ -291,8 +296,12 @@ fun UdroidApp( snapshot = snapshot, service = runtimeService, installedRootfses = installedRootfses, + uploadingTabId = terminalUploadTabId, + uploadMessage = terminalUploadMessage, + uploadFailed = terminalUploadFailed, onStart = onStart, onStop = onStop, + onUploadFile = onUploadTerminalFile, 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 de7f9a6..ad655e9 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt @@ -38,6 +38,7 @@ 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.material.icons.outlined.UploadFile import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -87,8 +88,12 @@ fun InteractiveTerminalPage( snapshot: RuntimeSnapshot, service: RuntimeSupervisorService?, installedRootfses: List, + uploadingTabId: String?, + uploadMessage: String?, + uploadFailed: Boolean, onStart: () -> Unit, onStop: () -> Unit, + onUploadFile: (TerminalTabSnapshot) -> Unit, onExit: () -> Unit, ) { val tabs = service?.terminalTabSnapshots().orEmpty() @@ -128,8 +133,10 @@ fun InteractiveTerminalPage( session = session, activeTab = activeTab, stopping = stopping, + uploading = uploadingTabId != null, onExit = onExit, onChooseDistro = { showDistroPicker = true }, + onUpload = { activeTab?.let(onUploadFile) }, onStop = { stopRequested = true onStop() @@ -161,6 +168,20 @@ fun InteractiveTerminalPage( ) } + uploadMessage?.let { message -> + Text( + text = message, + modifier = + Modifier + .fillMaxWidth() + .background(UdroidTerminalSurface) + .padding(horizontal = 14.dp, vertical = 6.dp), + color = if (uploadFailed) MaterialTheme.colorScheme.error else UdroidTerminalGreen, + style = MaterialTheme.typography.labelSmall, + maxLines = 2, + ) + } + if (stopping) { StoppingTerminalState( modifier = Modifier.weight(1f), @@ -215,8 +236,10 @@ private fun TerminalSessionBar( session: TerminalSession?, activeTab: TerminalTabSnapshot?, stopping: Boolean, + uploading: Boolean, onExit: () -> Unit, onChooseDistro: () -> Unit, + onUpload: () -> Unit, onStop: () -> Unit, ) { val running = session?.isRunning == true && !stopping @@ -287,6 +310,31 @@ private fun TerminalSessionBar( } } } + if (running) { + if (uploading) { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + color = UdroidTerminalGreen, + strokeWidth = 2.dp, + ) + } + } else { + IconButton( + onClick = onUpload, + enabled = activeTab != null, + ) { + Icon( + imageVector = Icons.Outlined.UploadFile, + contentDescription = "Upload file to terminal", + tint = UdroidTerminalText, + ) + } + } + } if (stopping) { Box( modifier = Modifier.size(48.dp), diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/TerminalFileImporterTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalFileImporterTest.kt new file mode 100644 index 0000000..885b7f2 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalFileImporterTest.kt @@ -0,0 +1,103 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +class TerminalFileImporterTest { + @Test + fun `imports stay inside the selected distro`() { + val parent = Files.createTempDirectory("udroid-terminal-upload").toFile() + try { + val ubuntu = parent.resolve("ubuntu").apply { resolve("root").mkdirs() } + val debian = parent.resolve("debian").apply { resolve("root").mkdirs() } + + val imported = + TerminalFileImporter.import( + rootfs = ubuntu, + sourceName = "../../server.pem", + source = ByteArrayInputStream("private key".toByteArray()), + ) + + assertEquals("~/Uploads/server.pem", imported) + assertEquals("private key", ubuntu.resolve("root/Uploads/server.pem").readText()) + assertEquals( + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + Files.getPosixFilePermissions(ubuntu.resolve("root/Uploads/server.pem").toPath()), + ) + assertFalse(debian.resolve("root/Uploads/server.pem").exists()) + assertFalse(parent.resolve("server.pem").exists()) + } finally { + parent.deleteRecursively() + } + } + + @Test + fun `failed imports remove partial files`() { + val rootfs = Files.createTempDirectory("udroid-terminal-upload").toFile() + rootfs.resolve("root").mkdirs() + try { + val failure = + runCatching { + TerminalFileImporter.import( + rootfs = rootfs, + sourceName = "script.sh", + source = FailingInputStream(), + ) + }.exceptionOrNull() + + assertTrue(failure is IOException) + assertTrue(rootfs.resolve("root/Uploads").listFiles().orEmpty().isEmpty()) + } finally { + rootfs.deleteRecursively() + } + } + + @Test + fun `existing upload is never overwritten`() { + val rootfs = Files.createTempDirectory("udroid-terminal-upload").toFile() + val existing = + rootfs.resolve("root/Uploads/config.json").apply { + requireNotNull(parentFile).mkdirs() + writeText("original") + } + try { + val failure = + runCatching { + TerminalFileImporter.import( + rootfs = rootfs, + sourceName = "config.json", + source = ByteArrayInputStream("replacement".toByteArray()), + ) + }.exceptionOrNull() + + assertTrue(failure is IllegalStateException) + assertEquals("original", existing.readText()) + } finally { + rootfs.deleteRecursively() + } + } + + private class FailingInputStream : InputStream() { + private var firstRead = true + + override fun read(): Int = error("Buffered reads are expected") + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (!firstRead) throw IOException("source disconnected") + firstRead = false + buffer[offset] = 'x'.code.toByte() + return 1 + } + } +}