Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions app/src/main/java/org/randomcoder/udroid/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -129,6 +137,9 @@ class MainActivity : ComponentActivity() {
private var linuxApplicationsState by
mutableStateOf<LinuxApplicationsState>(LinuxApplicationsState.Loading)
private var linuxApplicationMessage by mutableStateOf<String?>(null)
private var terminalUploadTabId by mutableStateOf<String?>(null)
private var terminalUploadMessage by mutableStateOf<String?>(null)
private var terminalUploadFailed by mutableStateOf(false)
private var pendingLinuxApplication: PendingLinuxApplication? = null
private var pendingLinuxApplicationId: String? = null
private var pendingDesktopStart: PendingDesktopStart? = null
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -244,6 +299,9 @@ class MainActivity : ComponentActivity() {
linuxApplicationMessage = linuxApplicationMessage,
showInstallTerminal = showInstallTerminal,
runtimeService = runtimeService,
terminalUploadTabId = terminalUploadTabId,
terminalUploadMessage = terminalUploadMessage,
terminalUploadFailed = terminalUploadFailed,
onDestinationSelected = ::selectDestination,
onPrimaryDestinationSelected = ::selectPrimaryDestination,
onStart = {
Expand All @@ -252,6 +310,7 @@ class MainActivity : ComponentActivity() {
selectDestination(UdroidDestination.TERMINAL)
},
onStop = { RuntimeSupervisorService.stop(this) },
onUploadTerminalFile = ::uploadTerminalFile,
onRefresh = { refreshAll() },
onReloadCatalogue = { loadCatalogue() },
onPreviewInstall = { selectDistro(it) },
Expand Down Expand Up @@ -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) !=
Expand Down

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation needs rewriting. Here, the import resolves the absolute path of rootfs / then proceeds to consider home as /root. If it does exist, then fall backs to / but at the end returns ~/Uploads/$fileName?

Here, the user can be logged in as any user; the return ~/ resolves to the home path of the currently logged-in user, which is not one of the possible places where this function stores the file, so unless the user is root and has /root the return would be invalid.

Original file line number Diff line number Diff line change
@@ -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"
}
Comment on lines +41 to +43

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check for an existing file is done only before the part upload; this does not guarantee that it won't exist or will be created in between the time when files are being copied from Android to the Linux path and moved to Uploads path.


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"
}
Comment on lines +71 to +83

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any specific reason MAX_FILE_NAME_CHARS is set? I think there is no need to trim the file names


private const val MAX_FILE_NAME_CHARS = 64
}
9 changes: 9 additions & 0 deletions app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) },
)
}
Expand Down
48 changes: 48 additions & 0 deletions app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,8 +88,12 @@ fun InteractiveTerminalPage(
snapshot: RuntimeSnapshot,
service: RuntimeSupervisorService?,
installedRootfses: List<InstalledRootfs>,
uploadingTabId: String?,
uploadMessage: String?,
uploadFailed: Boolean,
onStart: () -> Unit,
onStop: () -> Unit,
onUploadFile: (TerminalTabSnapshot) -> Unit,
onExit: () -> Unit,
) {
val tabs = service?.terminalTabSnapshots().orEmpty()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading