diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6775cab..56dbb12 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -59,6 +59,17 @@ Termux application code is not part of this checkpoint. The source is built inside this Gradle project so the APK and its native PTY bridge are reproducible from the checked-in tree. +## Terminal color profiles + +- Project: +- Source commit: `79338cb19f4b86cae4d2e81e6de60ba7f613bb9b` +- Imported profiles: Catppuccin Mocha, Gruvbox Dark, Nord, Tokyo Night, + Rosé Pine, and Ubuntu +- License: GNU General Public License version 3 + +The built-in values follow the corresponding Termux:Styling color profile +files. Theme names may identify their respective upstream projects. + ## Termux:X11 - Project: diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index af95702..83b2244 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -92,6 +92,17 @@ android:resource="@xml/update_file_paths" /> + + + + + + ?): Cursor { + val cursor = MatrixCursor(projection ?: ROOT_COLUMNS) + registry.all().forEach { rootfs -> + cursor.newRow() + .add(DocumentsContract.Root.COLUMN_ROOT_ID, rootfs.name) + .add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, RootfsDocumentId.encode(rootfs.name, "")) + .add(DocumentsContract.Root.COLUMN_TITLE, displayName(rootfs)) + .add(DocumentsContract.Root.COLUMN_SUMMARY, "uDroid Linux file system") + .add(DocumentsContract.Root.COLUMN_ICON, rootfsIconResource(rootfs.name)) + .add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD) + .add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*") + } + return cursor + } + + override fun queryDocument( + documentId: String, + projection: Array?, + ): Cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS).also { includeDocument(it, resolve(documentId)) } + + override fun queryChildDocuments( + parentDocumentId: String, + projection: Array?, + sortOrder: String?, + ): Cursor { + val parent = resolve(parentDocumentId) + if (!Files.isDirectory(parent.path)) throw FileNotFoundException("Not a directory") + val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS) + Files.newDirectoryStream(parent.path).use { children -> + children + .sortedBy { it.fileName.toString().lowercase() } + .forEach { child -> + val relative = listOf(parent.relative, child.fileName.toString()).filter(String::isNotEmpty).joinToString("/") + runCatching { resolve(RootfsDocumentId.encode(parent.rootfs.name, relative)) } + .getOrNull() + ?.takeIf { Files.isDirectory(it.path) || Files.isRegularFile(it.path) } + ?.let { includeDocument(cursor, it) } + } + } + return cursor + } + + override fun openDocument( + documentId: String, + mode: String, + signal: CancellationSignal?, + ): ParcelFileDescriptor { + if (mode != "r") throw FileNotFoundException("Linux filesystems are read-only in Android Files") + val document = resolve(documentId) + if (!Files.isRegularFile(document.path)) throw FileNotFoundException("Not a regular file") + return ParcelFileDescriptor.open(document.path.toFile(), ParcelFileDescriptor.MODE_READ_ONLY) + } + + override fun isChildDocument( + parentDocumentId: String, + documentId: String, + ): Boolean { + val parent = runCatching { resolve(parentDocumentId) }.getOrNull() ?: return false + val child = runCatching { resolve(documentId) }.getOrNull() ?: return false + return parent.rootfs.name == child.rootfs.name && + (parent.relative.isEmpty() || child.relative.startsWith(parent.relative + "/")) && + child.relative != parent.relative + } + + private fun includeDocument( + cursor: MatrixCursor, + document: ResolvedDocument, + ) { + val attributes = Files.readAttributes(document.path, BasicFileAttributes::class.java) + val directory = attributes.isDirectory + cursor.newRow() + .add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, RootfsDocumentId.encode(document.rootfs.name, document.relative)) + .add( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + if (document.relative.isEmpty()) displayName(document.rootfs) else document.relative.substringAfterLast('/'), + ) + .add(DocumentsContract.Document.COLUMN_MIME_TYPE, if (directory) DocumentsContract.Document.MIME_TYPE_DIR else mimeType(document.path)) + .add(DocumentsContract.Document.COLUMN_FLAGS, 0) + .add(DocumentsContract.Document.COLUMN_SIZE, if (directory) null else attributes.size()) + .add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, attributes.lastModifiedTime().toMillis()) + } + + private fun resolve(documentId: String): ResolvedDocument { + val id = runCatching { RootfsDocumentId.decode(documentId) } + .getOrElse { throw FileNotFoundException("Invalid document id") } + val rootfs = registry.all().firstOrNull { it.name == id.rootfs } + ?: throw FileNotFoundException("Linux system is not installed") + val path = runCatching { RootfsPathResolver.resolve(rootfs.directory.toPath(), id.relative) } + .getOrElse { throw FileNotFoundException("File is outside the Linux filesystem") } + if (!Files.exists(path)) throw FileNotFoundException("File does not exist") + return ResolvedDocument(rootfs, id.relative, path) + } + + private fun mimeType(path: Path): String = + URLConnection.guessContentTypeFromName(path.fileName.toString()) ?: "application/octet-stream" + + private fun displayName(rootfs: InstalledRootfs): String = + rootfsDisplayName( + rootfs.name, + runCatching { installSources.load(rootfs.name)?.displayName }.getOrNull(), + ) + + private data class ResolvedDocument( + val rootfs: InstalledRootfs, + val relative: String, + val path: Path, + ) + + private companion object { + val ROOT_COLUMNS = arrayOf( + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_SUMMARY, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_MIME_TYPES, + ) + val DOCUMENT_COLUMNS = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + ) + } +} + +internal fun rootfsDisplayName( + rootfsName: String, + installedDisplayName: String?, +): String = installedDisplayName?.takeIf(String::isNotBlank) ?: terminalDistroTitle(rootfsName) + +internal fun notifyRootfsDocumentsChanged(context: Context) { + runCatching { + context.contentResolver.notifyChange( + DocumentsContract.buildRootsUri("${context.packageName}.linux-files"), + null, + ) + }.onFailure { error -> + Log.w("uDroidFiles", "Could not notify Android Files about Linux systems", error) + } +} + +internal fun rootfsIconResource(name: String): Int { + val normalized = name.lowercase() + return when { + listOf("focal", "jammy", "noble", "oracular", "plucky", "questing", "resolute", "ubuntu") + .any(normalized::contains) -> R.drawable.distro_ubuntu + "debian" in normalized || "trixie" in normalized || "sid" in normalized -> R.drawable.distro_debian + "arch" in normalized -> R.drawable.distro_arch + "alpine" in normalized -> R.drawable.distro_alpine + "void" in normalized -> R.drawable.distro_void + else -> R.drawable.distro_unknown + } +} + +internal data class RootfsDocumentId( + val rootfs: String, + val relative: String, +) { + companion object { + private val encoder = Base64.getUrlEncoder().withoutPadding() + private val decoder = Base64.getUrlDecoder() + + fun encode(rootfs: String, relative: String): String = + "${encoder.encodeToString(rootfs.toByteArray(StandardCharsets.UTF_8))}:${encoder.encodeToString(relative.toByteArray(StandardCharsets.UTF_8))}" + + fun decode(value: String): RootfsDocumentId { + val separator = value.indexOf(':') + require(separator > 0) + val rootfs = String(decoder.decode(value.substring(0, separator)), StandardCharsets.UTF_8) + val relative = String(decoder.decode(value.substring(separator + 1)), StandardCharsets.UTF_8) + require(rootfs.isNotBlank() && '\u0000' !in rootfs) + require(relative.isEmpty() || relative.split('/').none { it.isEmpty() || it == "." || it == ".." }) + return RootfsDocumentId(rootfs, relative) + } + } +} + +internal object RootfsPathResolver { + fun resolve(root: Path, relative: String): Path { + val absoluteRoot = root.toAbsolutePath().normalize() + val pending = ArrayDeque(relative.split('/').filter(String::isNotEmpty)) + val resolved = ArrayDeque() + var symlinks = 0 + while (pending.isNotEmpty()) { + when (val part = pending.removeFirst()) { + "." -> Unit + ".." -> { + require(resolved.isNotEmpty()) + resolved.removeLast() + } + else -> { + val candidate = resolved.fold(absoluteRoot) { path, component -> path.resolve(component) }.resolve(part) + if (Files.isSymbolicLink(candidate)) { + require(++symlinks <= 40) + val target = Files.readSymbolicLink(candidate) + if (target.isAbsolute) resolved.clear() + target.map(Path::toString).asReversed().forEach(pending::addFirst) + } else { + resolved.addLast(part) + } + } + } + } + return resolved.fold(absoluteRoot) { path, component -> path.resolve(component) } + .normalize() + .also { require(it.startsWith(absoluteRoot)) } + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/install/AndroidRootfsRuntime.kt b/app/src/main/java/org/randomcoder/udroid/install/AndroidRootfsRuntime.kt index 326b5a2..1a2aac0 100644 --- a/app/src/main/java/org/randomcoder/udroid/install/AndroidRootfsRuntime.kt +++ b/app/src/main/java/org/randomcoder/udroid/install/AndroidRootfsRuntime.kt @@ -21,7 +21,16 @@ import java.io.InterruptedIOException import java.io.InputStreamReader import java.io.IOException import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.nio.channels.FileChannel import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.PosixFilePermission import java.util.concurrent.TimeUnit import java.util.zip.GZIPInputStream import kotlin.math.max @@ -308,25 +317,33 @@ class ProotTarExtractor( class AndroidRootfsConfigurator : RootfsConfigurator { override fun configure(rootfs: File) { + val writer = RootfsWriter(rootfs.toPath()) listOf("dev", "dev/shm", "proc", "sys", "tmp", "etc", "etc/profile.d").forEach { - check(File(rootfs, it).mkdirs() || File(rootfs, it).isDirectory) { - "Could not prepare /$it" - } + writer.ensureDirectory(it) } - replaceFile( - File(rootfs, "etc/hosts"), + writer.replaceFile( + "etc/hosts", """ 127.0.0.1 localhost 127.0.0.1 localhost.localdomain ::1 localhost ip6-localhost ip6-loopback """.trimIndent() + "\n", ) - replaceFile( - File(rootfs, "etc/resolv.conf"), + writer.replaceFile( + "etc/resolv.conf", "nameserver 1.1.1.1\nnameserver 8.8.8.8\n", ) - replaceFile( - File(rootfs, "etc/profile.d/udroid.sh"), + configureShellProfile(rootfs) + appendAndroidGroups(writer) + writer.makeExecutableIfRegular("usr/bin/sudo") + } + + fun configureShellProfile(rootfs: File) { + val writer = RootfsWriter(rootfs.toPath()) + writer.ensureDirectory("etc/profile.d") + writer.ensureDirectory("etc/udroid/shell-integration") + writer.replaceFile( + "etc/profile.d/udroid.sh", """ export ANDROID_ART_ROOT=${'$'}{ANDROID_ART_ROOT-} export ANDROID_DATA=${'$'}{ANDROID_DATA-} @@ -339,15 +356,140 @@ class AndroidRootfsConfigurator : RootfsConfigurator { export PULSE_SERVER=127.0.0.1 export TERM=${'$'}{TERM-xterm-256color} export TMPDIR=/tmp + + if [ -n "${'$'}{BASH_VERSION-}" ] && [ -r /etc/udroid/shell-integration/udroid.bash ]; then + . /etc/udroid/shell-integration/udroid.bash + fi """.trimIndent() + "\n", executable = true, ) - appendAndroidGroups(File(rootfs, "etc/group")) - File(rootfs, "usr/bin/sudo").takeIf(File::exists)?.setExecutable(true, false) + writer.replaceFile( + "etc/udroid/shell-integration/udroid.bash", + """ + case ${'$'}- in + *i*) ;; + *) return ;; + esac + [ -z "${'$'}{UDROID_BASH_INTEGRATION-}" ] || return + UDROID_BASH_INTEGRATION=1 + + __udroid_prompt_end='\[\e]133;B\a\]' + __udroid_last_cwd= + __udroid_prompt_command() { + __udroid_status=${'$'}? + printf '\033]133;D;%s\033\\\033]133;A\033\\' "${'$'}__udroid_status" + __udroid_cwd=${'$'}{PWD//[[:cntrl:]]/} + __udroid_cwd=${'$'}{__udroid_cwd//%/%25} + if [ "${'$'}__udroid_cwd" != "${'$'}__udroid_last_cwd" ]; then + printf '\033]7;kitty-shell-cwd://localhost%s\033\\' "${'$'}__udroid_cwd" + __udroid_last_cwd=${'$'}__udroid_cwd + fi + case ${'$'}PS1 in + *"${'$'}__udroid_prompt_end"*) ;; + *) PS1="${'$'}PS1${'$'}__udroid_prompt_end" ;; + esac + } + PS0="${'$'}{PS0-}"'\[\e]133;C\a\]' + case "${'$'}(declare -p PROMPT_COMMAND 2>/dev/null)" in + "declare -a"*) PROMPT_COMMAND+=(__udroid_prompt_command) ;; + *) + if [ -n "${'$'}{PROMPT_COMMAND-}" ]; then + PROMPT_COMMAND="${'$'}PROMPT_COMMAND;__udroid_prompt_command" + else + PROMPT_COMMAND=__udroid_prompt_command + fi + ;; + esac + """.trimIndent() + "\n", + ) + + if (listOf("bin/zsh", "usr/bin/zsh").any { File(rootfs, it).exists() }) { + writer.ensureDirectory("etc/zsh") + writer.replaceFile( + "etc/udroid/shell-integration/udroid.zsh", + """ + [[ -o interactive ]] || return + [[ -z "${'$'}{UDROID_ZSH_INTEGRATION-}" ]] || return + typeset -g UDROID_ZSH_INTEGRATION=1 + typeset -gi __udroid_command_running=0 + typeset -g __udroid_last_cwd= + autoload -Uz add-zsh-hook + + __udroid_report_cwd() { + local cwd=${'$'}{PWD//[[:cntrl:]]/} + cwd=${'$'}{cwd//%/%25} + [[ "${'$'}cwd" == "${'$'}__udroid_last_cwd" ]] && return + builtin print -n -- "\e]7;kitty-shell-cwd://localhost${'$'}cwd\e\\" + __udroid_last_cwd=${'$'}cwd + } + + __udroid_prompt_command() { + local command_status=${'$'}? + if (( __udroid_command_running )); then + builtin print -n -- "\e]133;D;${'$'}command_status\a" + fi + builtin print -n -- "\e]133;A\a" + __udroid_command_running=0 + __udroid_report_cwd + } + + __udroid_preexec() { + builtin print -n -- "\e]133;B\a\e]133;C\a" + __udroid_command_running=1 + } + + add-zsh-hook precmd __udroid_prompt_command + add-zsh-hook preexec __udroid_preexec + add-zsh-hook chpwd __udroid_report_cwd + """.trimIndent() + "\n", + ) + writer.ensureLine( + "etc/zsh/zshrc", + "[[ -r /etc/udroid/shell-integration/udroid.zsh ]] && builtin source /etc/udroid/shell-integration/udroid.zsh", + ) + } + + if (listOf("bin/fish", "usr/bin/fish").any { File(rootfs, it).exists() }) { + writer.ensureDirectory("etc/fish/conf.d") + writer.replaceFile( + "etc/fish/conf.d/udroid.fish", + """ + status is-interactive; or return + set -q UDROID_FISH_INTEGRATION; and return + set -g UDROID_FISH_INTEGRATION 1 + set -g __udroid_command_running 0 + + function __udroid_report_cwd --on-variable PWD + status is-command-substitution; and return + printf '\e]7;file://localhost%s\a' (string escape --style=url ${'$'}PWD) + end + + function __udroid_prompt_command --on-event fish_prompt + if test "${'$'}__udroid_command_running" = 1 + printf '\e]133;D\a' + end + printf '\e]133;A\a' + set -g __udroid_command_running 0 + end + + function __udroid_preexec --on-event fish_preexec + printf '\e]133;B\a\e]133;C\a' + set -g __udroid_command_running 1 + end + + function __udroid_postexec --on-event fish_postexec + printf '\e]133;D;%s\a' ${'$'}status + set -g __udroid_command_running 0 + end + + __udroid_report_cwd + """.trimIndent() + "\n", + ) + } } - private fun appendAndroidGroups(groupFile: File) { - val existing = if (groupFile.isFile) groupFile.readLines() else emptyList() + private fun appendAndroidGroups(writer: RootfsWriter) { + val existing = writer.readText("etc/group")?.lines().orEmpty() val existingGids = existing.mapNotNull { line -> line.split(':').getOrNull(2)?.toIntOrNull() }.toSet() val gids = @@ -363,32 +505,147 @@ class AndroidRootfsConfigurator : RootfsConfigurator { ?.distinct() .orEmpty() if (gids.isEmpty()) return - groupFile.parentFile?.mkdirs() - groupFile.appendText( - gids.joinToString(separator = "", transform = { "aid_$it:x:$it:root\n" }), + writer.replaceFile( + "etc/group", + buildString { + append(existing.joinToString("\n")) + if (isNotEmpty() && !endsWith('\n')) append('\n') + append(gids.joinToString(separator = "", transform = { "aid_$it:x:$it:root\n" })) + }, ) } +} + +private class RootfsWriter(rootfs: Path) { + private val root = rootfs.toAbsolutePath().normalize() + + init { + require(Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(root)) { + "Rootfs is not a real directory: $root" + } + } - private fun replaceFile( - target: File, + fun ensureDirectory(relative: String) { + var current = root + relativePath(relative).forEach { component -> + current = current.resolve(component) + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + check(Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(current)) { + "Rootfs path is not a real directory: $current" + } + } else { + Files.createDirectory(current) + } + } + } + + fun readText(relative: String): String? { + val target = resolveExistingFile(relative) ?: return null + check(Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(target)) { + "Rootfs path is not a regular file: $target" + } + return String(Files.readAllBytes(target), StandardCharsets.UTF_8) + } + + fun ensureLine( + relative: String, + line: String, + ) { + val existing = readText(relative).orEmpty() + if (existing.lineSequence().any { it == line }) return + replaceFile(relative, existing + (if (existing.isEmpty() || existing.endsWith('\n')) "" else "\n") + line + "\n") + } + + fun replaceFile( + relative: String, contents: String, executable: Boolean = false, ) { - target.parentFile?.mkdirs() - if (target.exists() || target.isSymbolicLink()) { - check(target.delete()) { "Could not replace ${target.path}" } + val target = resolveFile(relative) + val parent = checkNotNull(target.parent) + val existingPermissions = + target + .takeIf { Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(it) } + ?.let { Files.getPosixFilePermissions(it, LinkOption.NOFOLLOW_LINKS) } + ?: DEFAULT_FILE_PERMISSIONS + val targetPermissions = + if (executable) existingPermissions + EXECUTE_PERMISSIONS else existingPermissions + val staging = Files.createTempFile(parent, ".udroid-", ".tmp") + try { + FileChannel.open(staging, StandardOpenOption.WRITE).use { channel -> + val bytes = contents.toByteArray() + var buffer = ByteBuffer.wrap(bytes) + while (buffer.hasRemaining()) channel.write(buffer) + channel.force(true) + } + Files.setPosixFilePermissions(staging, targetPermissions) + Files.move( + staging, + target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } finally { + Files.deleteIfExists(staging) } - FileOutputStream(target).use { output -> - output.write(contents.toByteArray()) - output.fd.sync() + } + + fun makeExecutableIfRegular(relative: String) { + val target = resolveExistingFile(relative) ?: return + check(Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(target)) { + "Rootfs path is not a regular file: $target" } - target.setReadable(true, false) - target.setWritable(true, true) - if (executable) target.setExecutable(true, false) + Files.setPosixFilePermissions( + target, + Files.getPosixFilePermissions(target, LinkOption.NOFOLLOW_LINKS) + EXECUTE_PERMISSIONS, + ) } - private fun File.isSymbolicLink(): Boolean = - Files.isSymbolicLink(toPath()) + private fun resolveFile(relative: String): Path { + val relativePath = relativePath(relative) + val parent = relativePath.parent?.toString().orEmpty() + if (parent.isNotEmpty()) ensureDirectory(parent) + return root.resolve(relativePath) + } + + private fun resolveExistingFile(relative: String): Path? { + val relativePath = relativePath(relative) + var current = root + relativePath.forEachIndexed { index, component -> + current = current.resolve(component) + if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) return null + if (index < relativePath.nameCount - 1) { + check(Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(current)) { + "Rootfs path is not a real directory: $current" + } + } + } + return current + } + + private fun relativePath(relative: String): Path { + val path = Paths.get(relative) + require(!path.isAbsolute && path.none { it.toString() == ".." }) { + "Rootfs path must stay relative: $relative" + } + return path.normalize() + } + + private companion object { + val DEFAULT_FILE_PERMISSIONS = + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.GROUP_READ, + PosixFilePermission.OTHERS_READ, + ) + val EXECUTE_PERMISSIONS = + setOf( + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_EXECUTE, + ) + } } class ProotRootfsHealthCheck( diff --git a/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt b/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt index 905af48..903436d 100644 --- a/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt +++ b/app/src/main/java/org/randomcoder/udroid/install/InstallerService.kt @@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat import org.randomcoder.udroid.MainActivity import org.randomcoder.udroid.UdroidApplication import org.randomcoder.udroid.catalog.DistroVariant +import org.randomcoder.udroid.filesystem.notifyRootfsDocumentsChanged import org.randomcoder.udroid.oci.OciImageReference import org.randomcoder.udroid.oci.OciInstallEvent import org.randomcoder.udroid.oci.OciInstallStage @@ -387,6 +388,7 @@ class InstallerService : Service() { ) app.rootfsRegistry.setActiveIfNone(result.rootfs.name) rememberInstallSource(work) + notifyRootfsDocumentsChanged(this) progress.complete(result.reusedInstallation) app.journal.append( component = "installer", @@ -518,6 +520,7 @@ class InstallerService : Service() { val operationId = work.operationId app.rootfsRegistry.setActiveIfNone(rootfs.name) rememberInstallSource(work) + notifyRootfsDocumentsChanged(this) val previous = app.installState.current() val completed = InstallProgress( diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/InstalledRootfsRegistry.kt b/app/src/main/java/org/randomcoder/udroid/runtime/InstalledRootfsRegistry.kt index c4420bc..fecb374 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/InstalledRootfsRegistry.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/InstalledRootfsRegistry.kt @@ -2,6 +2,7 @@ package org.randomcoder.udroid.runtime import android.content.Context import org.randomcoder.udroid.UdroidApplication +import org.randomcoder.udroid.filesystem.notifyRootfsDocumentsChanged import org.randomcoder.udroid.install.RootfsInstallationPipeline import java.io.File import java.io.IOException @@ -84,6 +85,7 @@ class InstalledRootfsRegistry(context: Context) { "Failed to clear the active Linux system" } } + notifyRootfsDocumentsChanged(appContext) } private fun persistActive(name: String) { diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt index 174e533..39bd255 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt @@ -42,6 +42,8 @@ object ProotApplicationLaunchBuilder { ), ) val environmentProfile = ProotEnvironmentProfileStore(context).load(rootfs.name) + val compatibility = ProotCompatibilityConfigurationStore(context).load(rootfs.name) + val additionalProotArguments = ProotLaunchFlags.load() val guestEnvironment = ProotEnvironmentResolver.resolve(environmentProfile) val managedEnvironment = ProotEnvironmentResolver.resolveManagedOverrides(environmentProfile) @@ -56,6 +58,8 @@ object ProotApplicationLaunchBuilder { listOf(application.executable) + application.arguments, audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, launchProfile = launchProfile, + compatibility = compatibility, + additionalProotArguments = additionalProotArguments, mounts = mounts, guestEnvironment = guestEnvironment, managedEnvironment = managedEnvironment, @@ -96,6 +100,8 @@ object ProotApplicationLaunchBuilder { applicationArguments: List, audioAuthDirectory: String? = null, launchProfile: ProotLaunchProfile? = null, + compatibility: ProotCompatibilityConfiguration = ProotCompatibilityConfiguration(), + additionalProotArguments: List = emptyList(), mounts: List = ProotMountResolver.defaults(x11SocketDirectory, audioAuthDirectory), guestEnvironment: List = ProotEnvironmentResolver.resolve(), @@ -107,10 +113,12 @@ object ProotApplicationLaunchBuilder { add("--link2symlink") add("--kill-on-exit") add("--root-id") + addProotCompatibilityOptions(compatibility) add("--rootfs=$rootfsPath") addProotBindMounts(mounts) launchProfile?.addBindings(this) add("--cwd=$guestWorkingDirectory") + addAll(additionalProotArguments) add("/usr/bin/env") add("-i") add("HOME=$guestHome") diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotCompatibilityConfiguration.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotCompatibilityConfiguration.kt new file mode 100644 index 0000000..a8a46b5 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotCompatibilityConfiguration.kt @@ -0,0 +1,65 @@ +package org.randomcoder.udroid.runtime + +import android.content.Context + +data class ProotCompatibilityConfiguration( + val sysvipcEnabled: Boolean = false, + val memfdFallbackEnabled: Boolean = false, +) + +class ProotCompatibilityConfigurationStore(context: Context) { + private val preferences = + context.applicationContext + .getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + fun load(systemId: String): ProotCompatibilityConfiguration = + ProotCompatibilityConfiguration( + sysvipcEnabled = preferences.getBoolean(key(systemId, KEY_SYSVIPC), false), + memfdFallbackEnabled = preferences.getBoolean(key(systemId, KEY_MEMFD_FALLBACK), false), + ) + + fun save( + systemId: String, + configuration: ProotCompatibilityConfiguration, + ): ProotCompatibilityConfiguration { + check( + preferences + .edit() + .putBoolean(key(systemId, KEY_SYSVIPC), configuration.sysvipcEnabled) + .putBoolean(key(systemId, KEY_MEMFD_FALLBACK), configuration.memfdFallbackEnabled) + .commit(), + ) { + "Could not save PRoot compatibility settings for $systemId" + } + return configuration + } + + fun remove(systemId: String) { + val prefix = "$systemId:" + val editor = preferences.edit() + preferences.all.keys + .filter { it.startsWith(prefix) } + .forEach(editor::remove) + check(editor.commit()) { + "Could not clear PRoot compatibility settings for $systemId" + } + } + + private fun key( + systemId: String, + setting: String, + ): String = "$systemId:$setting" + + private companion object { + const val PREFERENCES_NAME = "proot-compatibility-configuration" + const val KEY_SYSVIPC = "sysvipc" + const val KEY_MEMFD_FALLBACK = "memfd-fallback" + } +} + +internal fun MutableList.addProotCompatibilityOptions( + configuration: ProotCompatibilityConfiguration, +) { + if (configuration.sysvipcEnabled) add("--sysvipc") + if (configuration.memfdFallbackEnabled) add("--ashmem-memfd") +} diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt index be5d0e7..265e888 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt @@ -34,6 +34,8 @@ object ProotDesktopLaunchBuilder { ), ) val environmentProfile = ProotEnvironmentProfileStore(context).load(rootfs.name) + val compatibility = ProotCompatibilityConfigurationStore(context).load(rootfs.name) + val additionalProotArguments = ProotLaunchFlags.load() val guestEnvironment = ProotEnvironmentResolver.resolve(environmentProfile) val managedEnvironment = ProotEnvironmentResolver.resolveManagedOverrides(environmentProfile) @@ -47,6 +49,8 @@ object ProotDesktopLaunchBuilder { configuration = configuration, audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, launchProfile = launchProfile, + compatibility = compatibility, + additionalProotArguments = additionalProotArguments, mounts = mounts, guestEnvironment = guestEnvironment, managedEnvironment = managedEnvironment, @@ -91,6 +95,8 @@ object ProotDesktopLaunchBuilder { hasDbusRunSession: Boolean, audioAuthDirectory: String? = null, launchProfile: ProotLaunchProfile? = null, + compatibility: ProotCompatibilityConfiguration = ProotCompatibilityConfiguration(), + additionalProotArguments: List = emptyList(), mounts: List = ProotMountResolver.defaults(x11SocketDirectory, audioAuthDirectory), guestEnvironment: List = ProotEnvironmentResolver.resolve(), @@ -101,10 +107,12 @@ object ProotDesktopLaunchBuilder { add("--link2symlink") add("--kill-on-exit") add("--root-id") + addProotCompatibilityOptions(compatibility) add("--rootfs=$rootfsPath") addProotBindMounts(mounts) launchProfile?.addBindings(this) add("--cwd=$guestHome") + addAll(additionalProotArguments) add("/usr/bin/env") add("-i") add("HOME=$guestHome") diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotLaunchFlags.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotLaunchFlags.kt new file mode 100644 index 0000000..0646890 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotLaunchFlags.kt @@ -0,0 +1,27 @@ +package org.randomcoder.udroid.runtime + +import android.os.Environment +import java.io.File + +object ProotLaunchFlags { + const val FILE_NAME = "udroid-launch-flags.txt" + + @Suppress("DEPRECATION") + fun load(): List = load(File(Environment.getExternalStorageDirectory(), FILE_NAME)) + + internal fun load(file: File): List { + if (!file.exists()) return emptyList() + require(file.isFile) { "${file.absolutePath} is not a file" } + require(file.length() <= MAX_FILE_SIZE) { + "$FILE_NAME is larger than ${MAX_FILE_SIZE / 1024} KiB" + } + + return file.bufferedReader().useLines { lines -> + lines + .map(String::trim) + .filter { it.isNotEmpty() && !it.startsWith('#') } + .toList() + } + } + private const val MAX_FILE_SIZE = 64L * 1024 +} diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt index 0c2688c..08194f1 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotMountProfiles.kt @@ -150,12 +150,13 @@ object ProotMountResolver { } else { emptyList() } - val selinuxMount = - prepareSelinuxMount(runtimeDirectory(context, rootfs).resolve("sysdata")) - ?.takeIf { - profile.isDefaultEnabled("android.sys") && - !isShadowedByCustomTarget(it.guestTarget, customTargets) - } + val systemCompatibilityMounts = + if (profile.isDefaultEnabled("android.sys")) { + prepareSystemCompatibilityMounts(runtimeDirectory(context, rootfs).resolve("sysdata")) + .filterNot { isShadowedByCustomTarget(it.guestTarget, customTargets) } + } else { + emptyList() + } val sharedMemoryMount = prepareSharedMemoryMount(runtimeDirectory(context, rootfs).resolve("shm")) .takeIf { !isShadowedByCustomTarget(it.guestTarget, customTargets) } @@ -164,7 +165,8 @@ object ProotMountResolver { sessionMounts = sessionMounts + compatibilityMounts + - listOfNotNull(selinuxMount, sharedMemoryMount), + systemCompatibilityMounts + + listOfNotNull(sharedMemoryMount), ) } @@ -253,49 +255,63 @@ object ProotMountResolver { @Synchronized internal fun prepareCompatibilityMounts( directory: File, + nowEpochSeconds: Long = System.currentTimeMillis() / 1_000L, + cpuCount: Int = Runtime.getRuntime().availableProcessors(), readable: (String) -> Boolean = ::isReadable, ): List { if (!prepareDirectory(directory)) return emptyList() - return COMPATIBILITY_FILES.mapNotNull { entry -> + val files = compatibilityFiles(nowEpochSeconds, cpuCount.coerceAtLeast(1)).mapNotNull { entry -> if (readable(entry.guestTarget)) return@mapNotNull null val source = File(directory, entry.name) - val staging = runCatching { - Files.createTempFile(directory.toPath(), ".compat-", ".tmp").toFile() - }.getOrNull() ?: return@mapNotNull null - try { - FileOutputStream(staging).use { output -> - output.write(entry.contents.toByteArray(Charsets.UTF_8)) - output.fd.sync() - } - moveReplacing(staging, source) - } catch (_: Exception) { - return@mapNotNull null - } finally { - staging.delete() - } - if (!Files.isRegularFile(source.toPath(), LinkOption.NOFOLLOW_LINKS)) { - return@mapNotNull null - } + if (!writeCompatibilityFile(directory, source, entry.contents)) return@mapNotNull null ResolvedProotMount( hostSource = source.absolutePath, guestTarget = entry.guestTarget, origin = "runtime:compatibility", ) } + val procNet = + if (readable("/proc/net/dev")) { + null + } else { + val source = File(directory, "proc_net") + val dev = File(source, "dev") + if ( + prepareDirectory(source) && + writeCompatibilityFile(source, dev, PROC_NET_DEV_HEADER) + ) { + ResolvedProotMount( + hostSource = source.absolutePath, + guestTarget = "/proc/net", + origin = "runtime:compatibility", + ) + } else { + null + } + } + return files + listOfNotNull(procNet) } - private fun prepareSelinuxMount(directory: File): ResolvedProotMount? { - if (!prepareDirectory(directory)) return null - val empty = File(directory, "sys_empty") - if (Files.isSymbolicLink(empty.toPath()) || (empty.exists() && !empty.isDirectory)) { - if (!empty.delete()) return null + @Synchronized + internal fun prepareSystemCompatibilityMounts( + directory: File, + readable: (String) -> Boolean = ::isDirectoryReadable, + ): List { + if (!prepareDirectory(directory)) return emptyList() + return listOf( + Triple("sys_empty", "/sys/fs/selinux", false), + Triple("sys_block", "/sys/block", true), + Triple("power_supply", "/sys/class/power_supply", true), + ).mapNotNull { (name, guestTarget, checkReadable) -> + if (checkReadable && readable(guestTarget)) return@mapNotNull null + val source = File(directory, name) + if (!prepareDirectory(source)) return@mapNotNull null + ResolvedProotMount( + hostSource = source.absolutePath, + guestTarget = guestTarget, + origin = "runtime:compatibility", + ) } - if (!empty.mkdirs() && !empty.isDirectory) return null - return ResolvedProotMount( - hostSource = empty.absolutePath, - guestTarget = "/sys/fs/selinux", - origin = "runtime:compatibility", - ) } private fun prepareDirectory(directory: File): Boolean = @@ -324,6 +340,8 @@ object ProotMountResolver { private fun isReadable(path: String): Boolean = runCatching { File(path).inputStream().use { it.read() } }.isSuccess + private fun isDirectoryReadable(path: String): Boolean = File(path).list() != null + fun defaults( x11SocketDirectory: String? = null, audioAuthDirectory: String? = null, @@ -379,17 +397,35 @@ object ProotMountResolver { val contents: String, ) - private val COMPATIBILITY_FILES = + private fun compatibilityFiles( + nowEpochSeconds: Long, + cpuCount: Int, + ) = listOf( CompatibilityFile("loadavg", "/proc/loadavg", "0.00 0.00 0.00 1/1 1\n"), - CompatibilityFile("stat", "/proc/stat", "cpu 1 0 1 1 0 0 0 0 0 0\n"), + CompatibilityFile( + "stat", + "/proc/stat", + buildString { + append("cpu $cpuCount 0 $cpuCount $cpuCount 0 0 0 0 0 0\n") + repeat(cpuCount) { index -> + append("cpu$index 1 0 1 1 0 0 0 0 0 0\n") + } + append("btime $nowEpochSeconds\n") + }, + ), CompatibilityFile("uptime", "/proc/uptime", "0.00 0.00\n"), CompatibilityFile( "version", "/proc/version", "Linux version 5.4.0-udroid-faked (udroid@android)\n", ), - CompatibilityFile("vmstat", "/proc/vmstat", "nr_free_pages 0\n"), + CompatibilityFile( + "vmstat", + "/proc/vmstat", + "nr_free_pages 0\npswpin 0\npswpout 0\n", + ), + CompatibilityFile("filesystems", "/proc/filesystems", ""), CompatibilityFile("cap_last_cap", "/proc/sys/kernel/cap_last_cap", "40\n"), CompatibilityFile( "inotify_max_user_watches", @@ -401,7 +437,33 @@ object ProotMountResolver { CompatibilityFile("pci_devices", "/proc/bus/pci/devices", ""), ) + private fun writeCompatibilityFile( + directory: File, + target: File, + contents: String, + ): Boolean { + val staging = runCatching { + Files.createTempFile(directory.toPath(), ".compat-", ".tmp").toFile() + }.getOrNull() ?: return false + return try { + FileOutputStream(staging).use { output -> + output.write(contents.toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + moveReplacing(staging, target) + Files.isRegularFile(target.toPath(), LinkOption.NOFOLLOW_LINKS) + } catch (_: Exception) { + false + } finally { + staging.delete() + } + } + private val SAFE_SYSTEM_ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,95}") + private const val PROC_NET_DEV_HEADER = + "Inter-| Receive | Transmit\n" + + " face |bytes packets errs drop fifo frame compressed multicast|" + + "bytes packets errs drop fifo colls carrier compressed\n" } class ProotMountProfileStore(context: Context) { diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt index 4c33195..2efa52e 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt @@ -1,7 +1,9 @@ package org.randomcoder.udroid.runtime import android.content.Context +import android.util.Log import org.randomcoder.udroid.audio.AudioEndpoint +import org.randomcoder.udroid.install.AndroidRootfsConfigurator import org.randomcoder.udroid.install.ProotRuntime import org.randomcoder.udroid.install.RootfsInstallationPipeline import java.io.File @@ -14,6 +16,7 @@ data class ProotTerminalLaunch( val environment: Array, val rootfs: File, val mounts: List, + val guestWorkingDirectory: String, ) object InstalledRootfsResolver { @@ -31,10 +34,15 @@ object ProotTerminalLaunchBuilder { x11SocketDirectory: File? = null, audioEndpoint: AudioEndpoint? = null, launchProfile: ProotLaunchProfile? = null, + requestedWorkingDirectory: String? = null, ): ProotTerminalLaunch { require(File(rootfs, RootfsInstallationPipeline.READY_MARKER).isFile) { "The selected Linux image is not ready" } + runCatching { AndroidRootfsConfigurator().configureShellProfile(rootfs) } + .onFailure { error -> + Log.w("uDroidTerminal", "Could not refresh guest shell integration", error) + } val linker = AndroidExecutableCommand.systemLinkerPath() val temporaryDirectory = File(context.cacheDir, "proot").apply { @@ -63,9 +71,13 @@ object ProotTerminalLaunchBuilder { ), ) val environmentProfile = ProotEnvironmentProfileStore(context).load(rootfs.name) + val compatibility = ProotCompatibilityConfigurationStore(context).load(rootfs.name) + val additionalProotArguments = ProotLaunchFlags.load() val guestEnvironment = ProotEnvironmentResolver.resolve(environmentProfile) val managedEnvironment = ProotEnvironmentResolver.resolveManagedOverrides(environmentProfile) + val guestWorkingDirectory = + resolveGuestWorkingDirectory(rootfs, mounts, requestedWorkingDirectory, guestHome) val arguments = buildArguments( @@ -73,10 +85,13 @@ object ProotTerminalLaunchBuilder { prootPath = runtime.executable.absolutePath, rootfsPath = ProotPathContract.rootfsPath(context, rootfs), guestHome = guestHome, + guestWorkingDirectory = guestWorkingDirectory, guestShell = guestShell, x11SocketDirectory = x11SocketDirectory?.absolutePath, audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, launchProfile = launchProfile, + compatibility = compatibility, + additionalProotArguments = additionalProotArguments, mounts = mounts, guestEnvironment = guestEnvironment, managedEnvironment = managedEnvironment, @@ -95,6 +110,7 @@ object ProotTerminalLaunchBuilder { environment = environment, rootfs = rootfs, mounts = mounts, + guestWorkingDirectory = guestWorkingDirectory, ) } @@ -103,10 +119,13 @@ object ProotTerminalLaunchBuilder { prootPath: String, rootfsPath: String, guestHome: String, + guestWorkingDirectory: String = guestHome, guestShell: String, x11SocketDirectory: String? = null, audioAuthDirectory: String? = null, launchProfile: ProotLaunchProfile? = null, + compatibility: ProotCompatibilityConfiguration = ProotCompatibilityConfiguration(), + additionalProotArguments: List = emptyList(), mounts: List = ProotMountResolver.defaults(x11SocketDirectory, audioAuthDirectory), guestEnvironment: List = ProotEnvironmentResolver.resolve(), @@ -119,10 +138,12 @@ object ProotTerminalLaunchBuilder { add("--link2symlink") add("--kill-on-exit") add("--root-id") + addProotCompatibilityOptions(compatibility) add("--rootfs=$rootfsPath") addProotBindMounts(mounts) launchProfile?.addBindings(this) - add("--cwd=$guestHome") + add("--cwd=$guestWorkingDirectory") + addAll(additionalProotArguments) add("/usr/bin/env") add("-i") add("HOME=$guestHome") @@ -169,4 +190,37 @@ object ProotTerminalLaunchBuilder { candidate.isFile || Files.isSymbolicLink(candidate.toPath()) } } + + internal fun resolveGuestWorkingDirectory( + rootfs: File, + mounts: List, + requestedPath: String?, + fallback: String, + ): String { + if (requestedPath == null || + requestedPath.length > 4096 || + !requestedPath.startsWith('/') || + requestedPath.any(Char::isISOControl) || + requestedPath.split('/').any { it == "." || it == ".." } + ) { + return fallback + } + val mount = + mounts + .filter { + requestedPath == it.guestTarget || + requestedPath.startsWith("${it.guestTarget.trimEnd('/')}/") + } + .maxByOrNull { it.guestTarget.length } + val hostPath = + if (mount == null) { + File(rootfs, requestedPath.removePrefix("/")) + } else { + File( + mount.hostSource, + requestedPath.removePrefix(mount.guestTarget).removePrefix("/"), + ) + } + return requestedPath.takeIf { hostPath.isDirectory } ?: fallback + } } 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 9ae0210..0b56582 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt @@ -26,6 +26,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import com.termux.terminal.TerminalColors +import com.termux.terminal.TerminalEmulator import com.termux.terminal.TerminalSession import com.termux.terminal.TerminalSessionClient import com.termux.view.TerminalView @@ -61,9 +62,10 @@ class RuntimeSupervisorService : Service() { private val binder = RuntimeBinder() private val mainHandler = Handler(Looper.getMainLooper()) private val terminalTabs = TerminalTabRegistry() - private val closingTerminalIds = mutableSetOf() private var nextTerminalNumber = 1 private var terminalCreationInFlight = false + private var terminalTabsRefreshPending = false + private var terminalCursorStyle = TerminalEmulator.DEFAULT_TERMINAL_CURSOR_STYLE private val ownedDesktop = AtomicReference(null) private val ownedGfxstreamHost = AtomicReference(null) private val ownedVirglHosts = ConcurrentHashMap() @@ -94,6 +96,35 @@ class RuntimeSupervisorService : Service() { override fun onTitleChanged(changedSession: TerminalSession) { onTextChanged(changedSession) + val title = normalizeTerminalTitle(changedSession.title) + mainHandler.post { + val tab = terminalTabs.findByValue(changedSession) ?: return@post + if (terminalTabs.updateShellTitle(tab.id, title) != null) { + scheduleTerminalTabsChanged() + } + } + } + + override fun onCommandStateChanged( + changedSession: TerminalSession, + running: Boolean, + ) { + mainHandler.post { + val tab = terminalTabs.findByValue(changedSession) ?: return@post + if (terminalTabs.updateCommandRunning(tab.id, running) != null) { + scheduleTerminalTabsChanged() + } + } + } + + override fun onWorkingDirectoryChanged( + changedSession: TerminalSession, + path: String, + ) { + mainHandler.post { + val tab = terminalTabs.findByValue(changedSession) ?: return@post + terminalTabs.updateWorkingDirectory(tab.id, path) + } } override fun onSessionFinished(finishedSession: TerminalSession) { @@ -152,7 +183,8 @@ class RuntimeSupervisorService : Service() { attachedViews.forEach(TerminalView::invalidate) } - override fun getTerminalCursorStyle(): Int? = null + override fun getTerminalCursorStyle(): Int = + this@RuntimeSupervisorService.terminalCursorStyle override fun logError( tag: String, @@ -197,6 +229,7 @@ class RuntimeSupervisorService : Service() { override fun onCreate() { super.onCreate() + terminalCursorStyle = TerminalTypographyStore(this).load().cursorStyle.emulatorValue() createNotificationChannel() app.journal.append( component = "supervisor", @@ -257,7 +290,6 @@ class RuntimeSupervisorService : Service() { 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) } @@ -285,10 +317,14 @@ class RuntimeSupervisorService : Service() { TerminalTabSnapshot( id = tab.id, title = tab.title, + hasCustomTitle = tab.customTitle != null, rootfsName = tab.rootfsName, pid = tab.value.pid.takeIf { it > 0 }?.toLong(), running = tab.value.isRunning, + commandRunning = tab.commandRunning, active = tab.id == terminalTabs.activeId, + closeState = tab.closeState, + closeError = tab.closeError, ) } @@ -309,6 +345,10 @@ class RuntimeSupervisorService : Service() { return } terminalCreationInFlight = true + val inheritedWorkingDirectory = + terminalTabs.active() + ?.takeIf { it.rootfsName == rootfsName } + ?.workingDirectory val x11SocketDirectory = x11Controller.activeSocketDirectory() val audioEndpoint = audioController.endpoint() applicationExecutor.execute { @@ -332,6 +372,7 @@ class RuntimeSupervisorService : Service() { audioEndpoint = audioEndpoint, launchProfile = graphicsLaunchProfile(graphicsProfile, virglServerMode, rootfs), + requestedWorkingDirectory = inheritedWorkingDirectory, ) } mainHandler.post { @@ -375,6 +416,12 @@ class RuntimeSupervisorService : Service() { } fun selectTerminalTab(id: String): Boolean { + val requested = terminalTabs.get(id) ?: return false + if (requested.closeState == TerminalTabCloseState.CLOSING || + requested.closeState == TerminalTabCloseState.FORCE_CLOSING + ) { + return false + } val tab = terminalTabs.select(id) ?: return false attachedViews.forEach { view -> if (view.mTermSession !== tab.value) view.attachSession(tab.value) @@ -388,8 +435,7 @@ class RuntimeSupervisorService : Service() { id: String, requestedTitle: String, ): Boolean { - val title = requestedTitle.trim().take(MAX_TERMINAL_TITLE_CHARS) - if (title.isBlank()) return false + val title = normalizeTerminalTitle(requestedTitle) ?: return false val tab = terminalTabs.rename(id, title) ?: return false publishTerminalTabState("Renamed terminal to ${tab.title}") app.journal.append( @@ -403,14 +449,29 @@ class RuntimeSupervisorService : Service() { return true } + fun followTerminalShellTitle(id: String): Boolean { + val tab = terminalTabs.followShellTitle(id) ?: return false + publishTerminalTabState("Following shell title for ${tab.title}") + app.journal.append( + component = "terminal", + severity = "info", + event = "tab_title_followed", + message = "Terminal tab now follows its shell title", + bootId = app.runtimeState.current().bootId, + fields = mapOf("tab_id" to id, "title" to tab.title), + ) + return true + } + fun closeTerminalTab(id: String): Boolean { - val tab = terminalTabs.get(id) ?: return false + if (terminalTabs.get(id) == null) return false if (terminalTabs.size() == 1) { stopRuntime(userRequested = true) return true } - closingTerminalIds += id - terminateTerminalTab(tab) + val closingTab = terminalTabs.beginClose(id) ?: return false + publishTerminalCloseState("Closing ${closingTab.title}") + terminateTerminalTab(closingTab) return true } @@ -498,6 +559,26 @@ class RuntimeSupervisorService : Service() { currentTerminalSession()?.takeIf(TerminalSession::isRunning)?.write(text) } + fun updateTerminalAppearance(profile: TerminalColorProfile) { + mainHandler.post { + configureTerminalColors(profile) + terminalTabs.all().forEach { tab -> + tab.value.emulator?.mColors?.reset() + tab.value.onColorsChanged() + } + } + } + + fun updateTerminalTypography(state: TerminalTypographyState) { + mainHandler.post { + terminalCursorStyle = state.cursorStyle.emulatorValue() + terminalTabs.all().forEach { tab -> + tab.value.emulator?.setCursorStyle() + terminalClient.onTextChanged(tab.value) + } + } + } + fun requestX11RendererConnection(callback: (ParcelFileDescriptor?) -> Unit) { x11Controller.requestRendererConnection(callback) } @@ -1443,7 +1524,6 @@ class RuntimeSupervisorService : Service() { return } terminalTabs.clear().forEach { it.value.finishIfRunning() } - closingTerminalIds.clear() nextTerminalNumber = 1 val bootId = UUID.randomUUID().toString() @@ -1577,9 +1657,10 @@ class RuntimeSupervisorService : Service() { val tab = TerminalTab( id = session.mHandle, - title = title, + defaultTitle = title, rootfsName = launch.rootfs.name, value = session, + workingDirectory = launch.guestWorkingDirectory, ) terminalTabs.add(tab) try { @@ -1621,22 +1702,24 @@ class RuntimeSupervisorService : Service() { publishState(next) } - private fun configureTerminalColors() { + private fun configureTerminalColors( + profile: TerminalColorProfile = TerminalProfileStore(this).load().selectedProfile, + ) { TerminalColors.COLOR_SCHEME.updateWith( Properties().apply { - setProperty("foreground", "#E3E7EF") - setProperty("background", "#11131F") - setProperty("cursor", "#43D292") - setProperty("color2", "#43D292") - setProperty("color3", "#E8B86D") - setProperty("color4", "#65A7D8") + setProperty("foreground", formatTerminalColor(profile.foreground)) + setProperty("background", formatTerminalColor(profile.background)) + setProperty("cursor", formatTerminalColor(profile.cursor)) + profile.ansiColors.forEachIndexed { index, color -> + setProperty("color$index", formatTerminalColor(color)) + } }, ) } private fun handleSessionFinished(session: TerminalSession) { val tab = terminalTabs.findByValue(session) ?: return - val individuallyClosed = closingTerminalIds.remove(tab.id) + val individuallyClosed = tab.closeState != TerminalTabCloseState.OPEN terminalTabs.remove(tab.id) val exitCode = session.exitStatus val beforeExit = app.runtimeState.current() @@ -1749,7 +1832,7 @@ class RuntimeSupervisorService : Service() { return } sessions.forEach { tab -> - closingTerminalIds += tab.id + terminalTabs.beginClose(tab.id) terminateTerminalTab(tab) } } @@ -1771,28 +1854,75 @@ class RuntimeSupervisorService : Service() { bootId = app.runtimeState.current().bootId, fields = mapOf("tab_id" to tab.id, "child_pid" to session.pid), ) + forceCloseTerminalTab(tab, session) + return } mainHandler.postDelayed( { if (terminalTabs.get(tab.id)?.value === session && session.isRunning) { + forceCloseTerminalTab(tab, session) + } + }, + GRACEFUL_STOP_TIMEOUT_MS, + ) + } + + private fun forceCloseTerminalTab( + tab: TerminalTab, + session: TerminalSession, + ) { + if (terminalTabs.get(tab.id)?.value !== session || !session.isRunning) return + terminalTabs.markForceClosing(tab.id) + publishTerminalCloseState("${tab.title} did not close; forcing it to stop") + app.journal.append( + component = "supervisor", + severity = "warning", + event = "terminal_force_stop", + message = "${tab.title} did not exit after SIGTERM; forcing it to stop", + bootId = app.runtimeState.current().bootId, + fields = mapOf("tab_id" to tab.id, "child_pid" to session.pid), + ) + session.finishIfRunning() + mainHandler.postDelayed( + { + if (terminalTabs.get(tab.id)?.value === session && session.isRunning) { + val message = "${tab.title} is still running after force stop" + terminalTabs.markCloseFailed(tab.id, message) + publishTerminalCloseState(message) app.journal.append( component = "supervisor", - severity = "warning", - event = "terminal_force_stop", - message = "${tab.title} ignored SIGTERM; forcing it to stop", + severity = "error", + event = "terminal_stop_failed", + message = message, bootId = app.runtimeState.current().bootId, fields = mapOf("tab_id" to tab.id, "child_pid" to session.pid), ) - session.finishIfRunning() } }, - GRACEFUL_STOP_TIMEOUT_MS, + FORCE_STOP_TIMEOUT_MS, + ) + } + + private fun publishTerminalCloseState(message: String) { + publishState(app.runtimeState.update { it.copy(message = message) }) + } + + private fun scheduleTerminalTabsChanged() { + if (terminalTabsRefreshPending) return + terminalTabsRefreshPending = true + mainHandler.postDelayed( + { + terminalTabsRefreshPending = false + sendBroadcast( + Intent(ACTION_TERMINAL_TABS_CHANGED).setPackage(packageName), + ) + }, + TERMINAL_TITLE_REFRESH_INTERVAL_MS, ) } private fun publishStoppedState() { terminalTabs.clear() - closingTerminalIds.clear() val stopped = app.runtimeState.update { it.copy( @@ -2033,6 +2163,8 @@ class RuntimeSupervisorService : Service() { companion object { const val ACTION_STATE_CHANGED = "org.randomcoder.udroid.action.STATE_CHANGED" + const val ACTION_TERMINAL_TABS_CHANGED = + "org.randomcoder.udroid.action.TERMINAL_TABS_CHANGED" const val EXTRA_PHASE = "phase" const val EXTRA_UPDATED_AT = "updated-at" const val EXTRA_ROOTFS_NAME = "rootfs-name" @@ -2044,6 +2176,8 @@ class RuntimeSupervisorService : Service() { private const val NOTIFICATION_CHANNEL = "runtime-supervisor" private const val NOTIFICATION_ID = 1001 private const val GRACEFUL_STOP_TIMEOUT_MS = 3_000L + private const val FORCE_STOP_TIMEOUT_MS = 3_000L + private const val TERMINAL_TITLE_REFRESH_INTERVAL_MS = 50L private const val TRANSCRIPT_ROWS = 8_000 private const val DEFAULT_COLUMNS = 80 private const val DEFAULT_ROWS = 24 @@ -2052,7 +2186,6 @@ 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( @@ -2094,3 +2227,10 @@ class RuntimeSupervisorService : Service() { val mounts: List, ) } + +private fun TerminalCursorStyle.emulatorValue(): Int = + when (this) { + TerminalCursorStyle.BLOCK -> TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK + TerminalCursorStyle.BAR -> TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR + TerminalCursorStyle.UNDERLINE -> TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE + } diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/TerminalAppearance.kt b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalAppearance.kt new file mode 100644 index 0000000..2ebba45 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalAppearance.kt @@ -0,0 +1,243 @@ +package org.randomcoder.udroid.runtime + +import android.content.Context +import androidx.annotation.ColorInt +import java.util.Base64 +import java.util.Locale + +const val TERMINAL_ANSI_COLOR_COUNT = 16 + +data class TerminalColorProfile( + val id: String, + val name: String, + val background: Int, + val foreground: Int, + val cursor: Int, + val ansiColors: List, +) { + init { + require(name.isNotBlank()) { "Profile name cannot be blank" } + require(ansiColors.size == TERMINAL_ANSI_COLOR_COUNT) { + "A terminal profile requires 16 ANSI colors" + } + } +} + +data class TerminalProfileState( + val selectedProfileId: String, + val userProfiles: List, +) { + val profiles: List + get() = TerminalProfiles.builtIns + userProfiles + + val selectedProfile: TerminalColorProfile + get() = profiles.firstOrNull { it.id == selectedProfileId } ?: TerminalProfiles.udroid +} + +object TerminalProfiles { + val udroid = + TerminalColorProfile( + id = "builtin:udroid", + name = "uDroid", + background = rgb("11131F"), + foreground = rgb("E3E7EF"), + cursor = rgb("43D292"), + ansiColors = + palette( + "000000", "CD0000", "43D292", "E8B86D", "65A7D8", "CD00CD", "00CDCD", "E5E5E5", + "7F7F7F", "FF0000", "00FF00", "FFFF00", "5C5CFF", "FF00FF", "00FFFF", "FFFFFF", + ), + ) + + private val solarizedAnsi = + palette( + "073642", "DC322F", "859900", "B58900", "268BD2", "D33682", "2AA198", "EEE8D5", + "002B36", "CB4B16", "586E75", "657B83", "839496", "6C71C4", "93A1A1", "FDF6E3", + ) + + val builtIns = + listOf( + udroid, + TerminalColorProfile( + id = "builtin:breeze", + name = "Breeze", + background = rgb("232627"), + foreground = rgb("FCFCFC"), + cursor = rgb("1D99F3"), + ansiColors = + palette( + "232627", "ED1515", "11D116", "F67400", "1D99F3", "9B59B6", "1ABC9C", "FCFCFC", + "7F8C8D", "C0392B", "1CDC9A", "FDBC4B", "3DAEE9", "8E44AD", "16A085", "FFFFFF", + ), + ), + TerminalColorProfile( + id = "builtin:solarized-dark", + name = "Solarized Dark", + background = rgb("002B36"), + foreground = rgb("839496"), + cursor = rgb("268BD2"), + ansiColors = solarizedAnsi, + ), + TerminalColorProfile( + id = "builtin:solarized-light", + name = "Solarized Light", + background = rgb("FDF6E3"), + foreground = rgb("657B83"), + cursor = rgb("268BD2"), + ansiColors = solarizedAnsi, + ), + TerminalColorProfile( + id = "builtin:dracula", + name = "Dracula", + background = rgb("282A36"), + foreground = rgb("F8F8F2"), + cursor = rgb("BD93F9"), + ansiColors = + palette( + "21222C", "FF5555", "50FA7B", "F1FA8C", "BD93F9", "FF79C6", "8BE9FD", "F8F8F2", + "6272A4", "FF6E6E", "69FF94", "FFFFA5", "D6ACFF", "FF92DF", "A4FFFF", "FFFFFF", + ), + ), + TerminalColorProfile( + id = "builtin:catppuccin-mocha", + name = "Catppuccin Mocha", + background = rgb("1E1E2E"), + foreground = rgb("CDD6F4"), + cursor = rgb("F5E0DC"), + ansiColors = + palette( + "45475A", "F38BA8", "A6E3A1", "F9E2AF", "89B4FA", "F5C2E7", "94E2D5", "BAC2DE", + "585B70", "F38BA8", "A6E3A1", "F9E2AF", "89B4FA", "F5C2E7", "94E2D5", "A6ADC8", + ), + ), + TerminalColorProfile( + id = "builtin:gruvbox-dark", + name = "Gruvbox Dark", + background = rgb("1D2021"), + foreground = rgb("EBDBB2"), + cursor = rgb("EBDBB2"), + ansiColors = + palette( + "282828", "CC241D", "98971A", "D79921", "458588", "B16286", "689D6A", "A89984", + "928374", "FB4934", "B8BB26", "FABD2F", "83A598", "D3869B", "8EC07C", "EBDBB2", + ), + ), + TerminalColorProfile( + id = "builtin:nord", + name = "Nord", + background = rgb("2E3440"), + foreground = rgb("D8DEE9"), + cursor = rgb("D8DEE9"), + ansiColors = + palette( + "3B4252", "BF616A", "A3BE8C", "EBCB8B", "81A1C1", "B48EAD", "88C0D0", "E5E8F0", + "4C566A", "BF616A", "A3BE8C", "EBCB8B", "81A1C1", "B48EAD", "8FBCBB", "ECEFF4", + ), + ), + TerminalColorProfile( + id = "builtin:tokyo-night", + name = "Tokyo Night", + background = rgb("1A1B26"), + foreground = rgb("C0CAF5"), + cursor = rgb("C0CAF5"), + ansiColors = + palette( + "15161E", "F7768E", "9ECE6A", "E0AF68", "7AA2F7", "BB9AF7", "7DCFFF", "A9B1D6", + "414868", "F7768E", "9ECE6A", "E0AF68", "7AA2F7", "BB9AF7", "7DCFFF", "C0CAF5", + ), + ), + TerminalColorProfile( + id = "builtin:rose-pine", + name = "Rosé Pine", + background = rgb("191724"), + foreground = rgb("E0DEF4"), + cursor = rgb("6E6A86"), + ansiColors = + palette( + "26233A", "EB6F92", "31748F", "F6C177", "9CCFD8", "C4A7E7", "EBBCBA", "E0DEF4", + "6E6A86", "EB6F92", "31748F", "F6C177", "9CCFD8", "C4A7E7", "EBBCBA", "E0DEF4", + ), + ), + TerminalColorProfile( + id = "builtin:ubuntu", + name = "Ubuntu", + background = rgb("300A24"), + foreground = rgb("FFFFFF"), + cursor = rgb("FFFFFF"), + ansiColors = + palette( + "2E3436", "CC0000", "4E9A06", "C4A000", "3465A4", "75507B", "06989A", "D3D7CF", + "555753", "EF2929", "8AE234", "FCE94F", "729FCF", "AD7FA8", "34E2E2", "EEEEEC", + ), + ), + ) +} + +class TerminalProfileStore(context: Context) { + private val preferences = + context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + fun load(): TerminalProfileState = + TerminalProfileState( + selectedProfileId = preferences.getString(KEY_SELECTED, null) ?: TerminalProfiles.udroid.id, + userProfiles = + preferences + .getStringSet(KEY_USER_PROFILES, emptySet()) + .orEmpty() + .mapNotNull(::decodeTerminalProfile) + .sortedBy(TerminalColorProfile::name), + ) + + fun save(state: TerminalProfileState) { + preferences + .edit() + .putString(KEY_SELECTED, state.selectedProfile.id) + .putStringSet(KEY_USER_PROFILES, state.userProfiles.map(::encodeTerminalProfile).toSet()) + .apply() + } + + private companion object { + const val PREFERENCES_NAME = "terminal-profiles" + const val KEY_SELECTED = "selected-profile" + const val KEY_USER_PROFILES = "user-profiles" + } +} + +internal fun parseTerminalColor(value: String): Int? { + val hex = value.trim().removePrefix("#") + if (hex.length != 6 || hex.any { it.digitToIntOrNull(16) == null }) return null + return (0xFF000000L or hex.toLong(16)).toInt() +} + +internal fun formatTerminalColor( + @ColorInt color: Int, +): String = String.format(Locale.US, "#%06X", color and 0xFFFFFF) + +internal fun encodeTerminalProfile(profile: TerminalColorProfile): String = + listOf( + profile.id, + Base64.getUrlEncoder().withoutPadding().encodeToString(profile.name.toByteArray()), + formatTerminalColor(profile.background), + formatTerminalColor(profile.foreground), + formatTerminalColor(profile.cursor), + profile.ansiColors.joinToString(",", transform = ::formatTerminalColor), + ).joinToString("|") + +internal fun decodeTerminalProfile(value: String): TerminalColorProfile? { + val fields = value.split('|') + if (fields.size != 6) return null + return runCatching { + TerminalColorProfile( + id = fields[0].takeIf(String::isNotBlank) ?: error("Missing id"), + name = String(Base64.getUrlDecoder().decode(fields[1])), + background = parseTerminalColor(fields[2]) ?: error("Invalid background"), + foreground = parseTerminalColor(fields[3]) ?: error("Invalid foreground"), + cursor = parseTerminalColor(fields[4]) ?: error("Invalid cursor"), + ansiColors = fields[5].split(',').map { parseTerminalColor(it) ?: error("Invalid ANSI color") }, + ) + }.getOrNull() +} + +private fun rgb(value: String): Int = checkNotNull(parseTerminalColor(value)) + +private fun palette(vararg values: String): List = values.map(::rgb) diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/TerminalProfileCodec.kt b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalProfileCodec.kt new file mode 100644 index 0000000..72a97b9 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalProfileCodec.kt @@ -0,0 +1,133 @@ +package org.randomcoder.udroid.runtime + +import org.w3c.dom.Element +import org.xml.sax.InputSource +import java.io.StringReader +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.math.roundToInt + +internal fun importTerminalProfile( + name: String, + contents: String, +): Result = + runCatching { + val profileName = name.substringBeforeLast('.').ifBlank { "Imported profile" } + if (contents.trimStart().startsWith(" + appendLine("color$index=${formatTerminalColor(color)}") + } + } + +private fun parseTextTheme( + name: String, + contents: String, +): TerminalColorProfile { + val colors = mutableMapOf() + val palette = mutableMapOf() + contents.lineSequence().forEach { source -> + val line = source.trim() + if (line.isEmpty() || line.startsWith('#')) return@forEach + GHOSTTY_PALETTE.matchEntire(line)?.let { match -> + val index = match.groupValues[1].toIntOrNull() ?: return@let + parseTerminalColor(match.groupValues[2])?.let { palette[index] = it } + return@forEach + } + TEXT_COLOR.matchEntire(line)?.let { match -> + val key = match.groupValues[1].lowercase() + val color = parseTerminalColor(match.groupValues[2]) ?: return@let + when { + key.startsWith("color") -> key.removePrefix("color").toIntOrNull()?.let { palette[it] = color } + else -> colors[key] = color + } + } + } + val foreground = colors["foreground"] ?: error("Theme is missing foreground") + val background = colors["background"] ?: error("Theme is missing background") + val ansi = (0 until TERMINAL_ANSI_COLOR_COUNT).map { palette[it] ?: error("Theme is missing color$it") } + return TerminalProfiles.udroid.copy( + id = "imported", + name = name, + background = background, + foreground = foreground, + cursor = colors["cursor"] ?: colors["cursor-color"] ?: foreground, + ansiColors = ansi, + ) +} + +private fun parseItermColors( + name: String, + contents: String, +): TerminalColorProfile { + val factory = DocumentBuilderFactory.newInstance() + runCatching { factory.isXIncludeAware = false } + runCatching { factory.isExpandEntityReferences = false } + XML_FEATURES.forEach { (feature, enabled) -> runCatching { factory.setFeature(feature, enabled) } } + val builder = factory.newDocumentBuilder().apply { + setEntityResolver { _, _ -> InputSource(StringReader("")) } + } + val root = builder.parse(InputSource(StringReader(contents))).documentElement + val dictionary = root.childElements().firstOrNull { it.tagName == "dict" } ?: error("Invalid iTerm2 palette") + val entries = dictionary.plistDictionary() + fun color(key: String): Int? = (entries[key] as? Element)?.plistColor() + val foreground = color("Foreground Color") ?: error("Palette is missing foreground") + return TerminalProfiles.udroid.copy( + id = "imported", + name = name, + background = color("Background Color") ?: error("Palette is missing background"), + foreground = foreground, + cursor = color("Cursor Color") ?: foreground, + ansiColors = + (0 until TERMINAL_ANSI_COLOR_COUNT).map { index -> + color("Ansi $index Color") ?: error("Palette is missing ANSI color $index") + }, + ) +} + +private fun Element.plistDictionary(): Map { + val children = childElements() + return buildMap { + var index = 0 + while (index + 1 < children.size) { + if (children[index].tagName == "key") put(children[index].textContent, children[index + 1]) + index += 2 + } + } +} + +private fun Element.plistColor(): Int { + val components = plistDictionary() + fun component(name: String): Int = + ((components[name]?.textContent?.toDoubleOrNull() ?: error("Color is missing $name")) * 255) + .roundToInt() + .coerceIn(0, 255) + return 0xFF000000.toInt() or + (component("Red Component") shl 16) or + (component("Green Component") shl 8) or + component("Blue Component") +} + +private fun Element.childElements(): List = + buildList { + val nodes = childNodes + for (index in 0 until nodes.length) (nodes.item(index) as? Element)?.let(::add) + } + +private val TEXT_COLOR = Regex("([A-Za-z0-9-]+)\\s*[:=]\\s*(#?[0-9A-Fa-f]{6})\\s*") +private val GHOSTTY_PALETTE = Regex("palette\\s*=\\s*(\\d+)\\s*=\\s*(#?[0-9A-Fa-f]{6})\\s*") +private val XML_FEATURES = + mapOf( + "http://xml.org/sax/features/external-general-entities" to false, + "http://xml.org/sax/features/external-parameter-entities" to false, + "http://apache.org/xml/features/nonvalidating/load-external-dtd" to false, + ) diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/TerminalTabs.kt b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalTabs.kt index 054847c..b669d4a 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/TerminalTabs.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalTabs.kt @@ -3,12 +3,23 @@ package org.randomcoder.udroid.runtime data class TerminalTabSnapshot( val id: String, val title: String, + val hasCustomTitle: Boolean, val rootfsName: String, val pid: Long?, val running: Boolean, + val commandRunning: Boolean, val active: Boolean, + val closeState: TerminalTabCloseState = TerminalTabCloseState.OPEN, + val closeError: String? = null, ) +enum class TerminalTabCloseState { + OPEN, + CLOSING, + FORCE_CLOSING, + FAILED, +} + fun terminalDistroTitle(rawName: String?): String = when { rawName.isNullOrBlank() -> "Linux" @@ -26,10 +37,19 @@ fun terminalDistroTitle(rawName: String?): String = internal data class TerminalTab( val id: String, - var title: String, + val defaultTitle: String, val rootfsName: String, val value: T, -) + var shellTitle: String? = null, + var customTitle: String? = null, + var commandRunning: Boolean = false, + var workingDirectory: String? = null, + var closeState: TerminalTabCloseState = TerminalTabCloseState.OPEN, + var closeError: String? = null, +) { + val title: String + get() = customTitle ?: shellTitle ?: defaultTitle +} internal class TerminalTabRegistry { private val tabs = LinkedHashMap>() @@ -64,10 +84,73 @@ internal class TerminalTabRegistry { title: String, ): TerminalTab? { val tab = tabs[id] ?: return null - tab.title = title + tab.customTitle = title + return tab + } + + fun updateShellTitle( + id: String, + title: String?, + ): TerminalTab? { + val tab = tabs[id] ?: return null + if (tab.shellTitle == title) return null + tab.shellTitle = title + return tab + } + + fun followShellTitle(id: String): TerminalTab? { + val tab = tabs[id]?.takeIf { it.customTitle != null } ?: return null + tab.customTitle = null + return tab + } + + fun updateCommandRunning( + id: String, + running: Boolean, + ): TerminalTab? { + val tab = tabs[id] ?: return null + if (tab.commandRunning == running) return null + tab.commandRunning = running return tab } + fun updateWorkingDirectory( + id: String, + path: String, + ): TerminalTab? { + val tab = tabs[id] ?: return null + if (tab.workingDirectory == path) return null + tab.workingDirectory = path + return tab + } + + fun beginClose(id: String): TerminalTab? { + val tab = tabs[id] ?: return null + if (tab.closeState == TerminalTabCloseState.CLOSING || + tab.closeState == TerminalTabCloseState.FORCE_CLOSING + ) { + return null + } + tab.closeState = TerminalTabCloseState.CLOSING + tab.closeError = null + return tab + } + + fun markForceClosing(id: String): TerminalTab? = + tabs[id]?.also { + it.closeState = TerminalTabCloseState.FORCE_CLOSING + it.closeError = null + } + + fun markCloseFailed( + id: String, + message: String, + ): TerminalTab? = + tabs[id]?.also { + it.closeState = TerminalTabCloseState.FAILED + it.closeError = message + } + fun remove(id: String): TerminalTab? { val index = tabs.keys.indexOf(id) val removed = tabs.remove(id) ?: return null @@ -86,3 +169,14 @@ internal class TerminalTabRegistry { fun size(): Int = tabs.size } + +internal fun normalizeTerminalTitle(rawTitle: String?): String? = + rawTitle + ?.filter { character -> + !character.isISOControl() && + Character.getType(character) != Character.FORMAT.toInt() + }?.trim() + ?.take(MAX_TERMINAL_TITLE_CHARS) + ?.ifEmpty { null } + +internal const val MAX_TERMINAL_TITLE_CHARS = 40 diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/TerminalTypography.kt b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalTypography.kt new file mode 100644 index 0000000..76255c8 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalTypography.kt @@ -0,0 +1,352 @@ +package org.randomcoder.udroid.runtime + +import android.content.Context +import android.graphics.Typeface +import android.net.Uri +import android.os.Build +import android.util.TypedValue +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.Charset +import java.util.Base64 +import java.util.UUID + +const val TERMINAL_TEXT_SIZE_MIN_SP = 4f +const val TERMINAL_TEXT_SIZE_MAX_SP = 96f +const val TERMINAL_LINE_SPACING_MIN = 0.8f +const val TERMINAL_LINE_SPACING_MAX = 1.6f +const val TERMINAL_CURSOR_BLINK_RATE_DEFAULT_MS = 600 +const val TERMINAL_CURSOR_BLINK_RATE_MIN_MS = 100 +const val TERMINAL_CURSOR_BLINK_RATE_MAX_MS = 2000 +private val SAFE_FONT_FILE = Regex("[0-9a-f-]+\\.(ttf|otf)") + +enum class TerminalCursorStyle { + BLOCK, + BAR, + UNDERLINE, +} + +data class TerminalFontProfile( + val id: String, + val name: String, + val fileName: String?, + val assetPath: String? = null, +) + +data class TerminalTypographyState( + val selectedFontId: String = TerminalFonts.system.id, + val importedFonts: List = emptyList(), + val textSizeSp: Float = 15f, + val lineSpacing: Float = 1f, + val cursorStyle: TerminalCursorStyle = TerminalCursorStyle.BLOCK, + val cursorBlinkRateMs: Int = 0, +) { + val fonts: List + get() = TerminalFonts.builtIns + importedFonts + + val selectedFont: TerminalFontProfile + get() = fonts.firstOrNull { it.id == selectedFontId } ?: TerminalFonts.system + + fun normalized(): TerminalTypographyState = + copy( + selectedFontId = selectedFont.id, + importedFonts = importedFonts.distinctBy(TerminalFontProfile::id).sortedBy(TerminalFontProfile::name), + textSizeSp = textSizeSp.coerceIn(TERMINAL_TEXT_SIZE_MIN_SP, TERMINAL_TEXT_SIZE_MAX_SP), + lineSpacing = lineSpacing.coerceIn(TERMINAL_LINE_SPACING_MIN, TERMINAL_LINE_SPACING_MAX), + cursorBlinkRateMs = + if (cursorBlinkRateMs == 0) { + 0 + } else { + cursorBlinkRateMs.coerceIn( + TERMINAL_CURSOR_BLINK_RATE_MIN_MS, + TERMINAL_CURSOR_BLINK_RATE_MAX_MS, + ) + }, + ) +} + +object TerminalFonts { + val system = + TerminalFontProfile( + id = "builtin:monospace", + name = "System monospace", + fileName = null, + ) + val jetBrainsMono = + TerminalFontProfile( + id = "builtin:jetbrains-mono", + name = "JetBrains Mono", + fileName = null, + assetPath = "terminal-fonts/JetBrainsMono-Regular.ttf", + ) + val firaCode = + TerminalFontProfile( + id = "builtin:fira-code", + name = "Fira Code", + fileName = null, + assetPath = "terminal-fonts/FiraCode-Regular.ttf", + ) + val sourceCodePro = + TerminalFontProfile( + id = "builtin:source-code-pro", + name = "Source Code Pro", + fileName = null, + assetPath = "terminal-fonts/SourceCodePro-Regular.otf", + ) + val builtIns = listOf(system, jetBrainsMono, firaCode, sourceCodePro) +} + +class TerminalTypographyStore(context: Context) { + private val appContext = context.applicationContext + private val preferences = appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private val fontsDirectory = File(appContext.filesDir, "terminal-fonts") + + fun load(): TerminalTypographyState { + var importedFonts = + preferences + .getStringSet(KEY_IMPORTED_FONTS, emptySet()) + .orEmpty() + .mapNotNull(::decodeTerminalFont) + .filter { font -> font.fileName?.let(::fontFile)?.isFile == true } + if (!preferences.getBoolean(KEY_EMBEDDED_NAMES_MIGRATED, false)) { + importedFonts = + importedFonts.map { font -> + val embeddedName = font.fileName?.let(::fontFile)?.let(::readOpenTypeFontName) + if (embeddedName == null) font else font.copy(name = embeddedName) + } + preferences + .edit() + .putStringSet(KEY_IMPORTED_FONTS, importedFonts.map(::encodeTerminalFont).toSet()) + .putBoolean(KEY_EMBEDDED_NAMES_MIGRATED, true) + .apply() + } + val cursorStyle = + runCatching { + TerminalCursorStyle.valueOf( + preferences.getString(KEY_CURSOR_STYLE, null) ?: TerminalCursorStyle.BLOCK.name, + ) + }.getOrDefault(TerminalCursorStyle.BLOCK) + return TerminalTypographyState( + selectedFontId = preferences.getString(KEY_SELECTED_FONT, null) ?: TerminalFonts.system.id, + importedFonts = importedFonts, + textSizeSp = + if (preferences.contains(KEY_TEXT_SIZE_SP)) { + preferences.getFloat(KEY_TEXT_SIZE_SP, DEFAULT_TEXT_SIZE_SP) + } else { + val legacyTextSizePx = + appContext + .getSharedPreferences(LEGACY_PREFERENCES_NAME, Context.MODE_PRIVATE) + .getInt(LEGACY_TEXT_SIZE_PX, -1) + if (legacyTextSizePx > 0) { + legacyTextSizePx.toSp() + } else { + DEFAULT_TEXT_SIZE_SP + } + }, + lineSpacing = preferences.getFloat(KEY_LINE_SPACING, 1f), + cursorStyle = cursorStyle, + cursorBlinkRateMs = preferences.getInt(KEY_CURSOR_BLINK_RATE, 0), + ).normalized() + } + + fun save(state: TerminalTypographyState) { + val normalized = state.normalized() + preferences + .edit() + .putString(KEY_SELECTED_FONT, normalized.selectedFontId) + .putStringSet(KEY_IMPORTED_FONTS, normalized.importedFonts.map(::encodeTerminalFont).toSet()) + .putFloat(KEY_TEXT_SIZE_SP, normalized.textSizeSp) + .putFloat(KEY_LINE_SPACING, normalized.lineSpacing) + .putString(KEY_CURSOR_STYLE, normalized.cursorStyle.name) + .putInt(KEY_CURSOR_BLINK_RATE, normalized.cursorBlinkRateMs) + .apply() + } + + fun importFont( + uri: Uri, + sourceName: String, + ): Result = + runCatching { + val extension = sourceName.substringAfterLast('.', "").lowercase() + require(extension == "ttf" || extension == "otf") { "Choose a .ttf or .otf font" } + fontsDirectory.mkdirs() + val id = UUID.randomUUID().toString() + val destination = File(fontsDirectory, "$id.$extension") + val temporary = File(fontsDirectory, ".$id.tmp") + try { + val input = appContext.contentResolver.openInputStream(uri) ?: error("Could not open font") + input.buffered().use { source -> + temporary.outputStream().buffered().use { target -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0L + while (true) { + val count = source.read(buffer) + if (count < 0) break + total += count + require(total <= MAX_FONT_FILE_BYTES) { "Font is larger than 16 MiB" } + target.write(buffer, 0, count) + } + } + } + requireNotNull(Typeface.Builder(temporary).build()) { "Android could not read this font" } + val embeddedName = readOpenTypeFontName(temporary) + check(temporary.renameTo(destination)) { "Could not store font" } + TerminalFontProfile( + id = id, + name = + embeddedName + ?: sourceName.substringBeforeLast('.').ifBlank { "Imported font" }.take(80), + fileName = destination.name, + ) + } finally { + temporary.delete() + } + } + + fun loadTypeface(font: TerminalFontProfile): Typeface = + font.assetPath + ?.let { runCatching { Typeface.Builder(appContext.assets, it).build() }.getOrNull() } + ?: font.fileName + ?.let(::fontFile) + ?.takeIf(File::isFile) + ?.let { runCatching { Typeface.Builder(it).build() }.getOrNull() } + ?: Typeface.MONOSPACE + + fun delete(font: TerminalFontProfile) { + font.fileName?.let(::fontFile)?.delete() + } + + private fun fontFile(fileName: String): File { + require(SAFE_FONT_FILE.matches(fileName)) { "Invalid font file" } + return File(fontsDirectory, fileName) + } + + private fun Int.toSp(): Float { + val metrics = appContext.resources.displayMetrics + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + TypedValue.deriveDimension(TypedValue.COMPLEX_UNIT_SP, toFloat(), metrics) + } else { + @Suppress("DEPRECATION") + val scaledDensity = metrics.scaledDensity + this / scaledDensity + } + } + + private companion object { + const val PREFERENCES_NAME = "terminal-typography" + const val KEY_SELECTED_FONT = "selected-font" + const val KEY_IMPORTED_FONTS = "imported-fonts" + const val KEY_TEXT_SIZE_SP = "text-size-sp" + const val KEY_LINE_SPACING = "line-spacing" + const val KEY_CURSOR_STYLE = "cursor-style" + const val KEY_CURSOR_BLINK_RATE = "cursor-blink-rate" + const val KEY_EMBEDDED_NAMES_MIGRATED = "embedded-names-migrated-v1" + const val LEGACY_PREFERENCES_NAME = "terminal-view" + const val LEGACY_TEXT_SIZE_PX = "text-size-px" + const val DEFAULT_TEXT_SIZE_SP = 15f + const val MAX_FONT_FILE_BYTES = 16L * 1024 * 1024 + } +} + +internal fun encodeTerminalFont(font: TerminalFontProfile): String = + listOf( + font.id, + Base64.getUrlEncoder().withoutPadding().encodeToString(font.name.toByteArray()), + font.fileName.orEmpty(), + ).joinToString("|") + +internal fun decodeTerminalFont(value: String): TerminalFontProfile? { + val fields = value.split('|') + if (fields.size != 3 || fields[0].isBlank() || fields[2].isBlank()) return null + return runCatching { + TerminalFontProfile( + id = fields[0], + name = String(Base64.getUrlDecoder().decode(fields[1])).takeIf(String::isNotBlank) ?: error("Missing name"), + fileName = fields[2].takeIf(SAFE_FONT_FILE::matches) + ?: error("Invalid file"), + ) + }.getOrNull() +} + +internal fun readOpenTypeFontName(file: File): String? = + runCatching { + val bytes = file.readBytes() + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + if (bytes.size < 12) return null + val tableCount = buffer.unsignedShort(4) + if (12L + tableCount * 16L > bytes.size) return null + + var nameOffset = -1 + var nameLength = 0 + repeat(tableCount) { index -> + val record = 12 + index * 16 + if ( + buffer.get(record) == 'n'.code.toByte() && + buffer.get(record + 1) == 'a'.code.toByte() && + buffer.get(record + 2) == 'm'.code.toByte() && + buffer.get(record + 3) == 'e'.code.toByte() + ) { + val offset = buffer.unsignedInt(record + 8) + val length = buffer.unsignedInt(record + 12) + if (offset + length <= bytes.size && offset <= Int.MAX_VALUE && length <= Int.MAX_VALUE) { + nameOffset = offset.toInt() + nameLength = length.toInt() + } + } + } + if (nameOffset < 0 || nameLength < 6) return null + + val tableEnd = nameOffset.toLong() + nameLength + val recordCount = buffer.unsignedShort(nameOffset + 2) + val stringStorage = nameOffset + buffer.unsignedShort(nameOffset + 4) + if (nameOffset + 6L + recordCount * 12L > tableEnd || stringStorage !in nameOffset..tableEnd.toInt()) { + return null + } + + var bestName: String? = null + var bestRank = Int.MAX_VALUE + repeat(recordCount) { index -> + val record = nameOffset + 6 + index * 12 + val platformId = buffer.unsignedShort(record) + val languageId = buffer.unsignedShort(record + 4) + val nameId = buffer.unsignedShort(record + 6) + val nameRank = + when (nameId) { + 4 -> 0 + 16 -> 10 + 1 -> 20 + else -> return@repeat + } + val platformRank = + when { + platformId == 3 && languageId == 0x0409 -> 0 + platformId == 0 -> 1 + platformId == 3 -> 2 + platformId == 1 && languageId == 0 -> 3 + else -> return@repeat + } + val length = buffer.unsignedShort(record + 8) + val offset = stringStorage.toLong() + buffer.unsignedShort(record + 10) + if (offset + length > tableEnd || offset > Int.MAX_VALUE) return@repeat + val encoded = bytes.copyOfRange(offset.toInt(), offset.toInt() + length) + val decoded = + when (platformId) { + 0, 3 -> String(encoded, Charsets.UTF_16BE) + 1 -> String(encoded, MAC_ROMAN) + else -> return@repeat + }.replace(Regex("\\s+"), " ").trim().take(80) + val rank = nameRank + platformRank + if (decoded.isNotEmpty() && rank < bestRank) { + bestName = decoded + bestRank = rank + } + } + bestName + }.getOrNull() + +private fun ByteBuffer.unsignedShort(offset: Int): Int = getShort(offset).toInt() and 0xffff + +private fun ByteBuffer.unsignedInt(offset: Int): Long = getInt(offset).toLong() and 0xffffffffL + +private val MAC_ROMAN: Charset = runCatching { Charset.forName("x-MacRoman") }.getOrDefault(Charsets.ISO_8859_1) 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..6eb288b 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,12 @@ 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.TerminalColorProfile +import org.randomcoder.udroid.runtime.TerminalProfileState +import org.randomcoder.udroid.runtime.TerminalProfileStore +import org.randomcoder.udroid.runtime.TerminalProfiles +import org.randomcoder.udroid.runtime.TerminalTypographyState +import org.randomcoder.udroid.runtime.TerminalTypographyStore import org.randomcoder.udroid.runtime.VirglServerMode import org.randomcoder.udroid.update.AppUpdatePhase import org.randomcoder.udroid.update.AppUpdateState @@ -201,6 +207,7 @@ fun UdroidApp( linuxApplicationMessage: String?, showInstallTerminal: Boolean, runtimeService: RuntimeSupervisorService?, + onTerminalAppearanceChanged: () -> Unit, onDestinationSelected: (UdroidDestination) -> Unit, onPrimaryDestinationSelected: (UdroidDestination) -> Unit, onStart: () -> Unit, @@ -243,6 +250,15 @@ fun UdroidApp( onInstallUpdate: () -> Unit, onOpenUpdateRelease: () -> Unit, ) { + val context = LocalContext.current + val terminalProfileStore = remember(context) { TerminalProfileStore(context) } + val terminalTypographyStore = remember(context) { TerminalTypographyStore(context) } + var terminalProfileState by remember(context) { + mutableStateOf(terminalProfileStore.load()) + } + var terminalTypographyState by remember(context) { + mutableStateOf(terminalTypographyStore.load()) + } var mountConfigurationSourceSystemId by rememberSaveable { mutableStateOf(null) } var selectedMountProfileSystemId by rememberSaveable { mutableStateOf(null) } val hasInstalledLinux = installedRootfsName != null @@ -275,7 +291,19 @@ fun UdroidApp( } if (activeDestination == UdroidDestination.TERMINAL) { - UdroidTerminalTheme { + fun applyTerminalProfiles(state: TerminalProfileState) { + terminalProfileStore.save(state) + terminalProfileState = state + runtimeService?.updateTerminalAppearance(state.selectedProfile) + onTerminalAppearanceChanged() + } + fun applyTerminalTypography(state: TerminalTypographyState) { + val normalized = state.normalized() + terminalTypographyStore.save(normalized) + terminalTypographyState = normalized + runtimeService?.updateTerminalTypography(normalized) + } + UdroidTerminalTheme(terminalProfileState.selectedProfile) { BackHandler { onDestinationSelected(UdroidDestination.SYSTEM) } @@ -291,6 +319,31 @@ fun UdroidApp( snapshot = snapshot, service = runtimeService, installedRootfses = installedRootfses, + profileState = terminalProfileState, + typographyState = terminalTypographyState, + onSelectProfile = { profileId -> + applyTerminalProfiles(terminalProfileState.copy(selectedProfileId = profileId)) + }, + onSaveProfile = { profile: TerminalColorProfile -> + val profiles = terminalProfileState.userProfiles.filterNot { it.id == profile.id } + profile + applyTerminalProfiles( + terminalProfileState.copy( + selectedProfileId = profile.id, + userProfiles = profiles, + ), + ) + }, + onDeleteProfile = { profileId -> + val selected = terminalProfileState.selectedProfileId == profileId + applyTerminalProfiles( + terminalProfileState.copy( + selectedProfileId = + if (selected) TerminalProfiles.udroid.id else terminalProfileState.selectedProfileId, + userProfiles = terminalProfileState.userProfiles.filterNot { it.id == profileId }, + ), + ) + }, + onTypographyChanged = ::applyTerminalTypography, onStart = onStart, onStop = onStop, onExit = { onDestinationSelected(UdroidDestination.SYSTEM) }, diff --git a/app/src/main/java/org/randomcoder/udroid/ui/DesignSystem.kt b/app/src/main/java/org/randomcoder/udroid/ui/DesignSystem.kt index 2eea8aa..34c267b 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/DesignSystem.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/DesignSystem.kt @@ -13,9 +13,15 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import org.randomcoder.udroid.runtime.TerminalColorProfile +import org.randomcoder.udroid.runtime.TerminalProfiles private val UdroidLightColors = lightColorScheme( @@ -128,14 +134,77 @@ val UdroidWarning: Color val UdroidWarningSurface: Color @Composable get() = if (isSystemInDarkTheme()) Color(0xFF3B2F00) else Color(0xFFFFE08B) +private data class TerminalPalette( + val background: Color, + val surface: Color, + val raised: Color, + val line: Color, + val foreground: Color, + val muted: Color, + val accent: Color, + val accentContainer: Color, + val onAccent: Color, + val error: Color, + val onError: Color, + val errorContainer: Color, + val onErrorContainer: Color, +) + +private fun terminalPalette(profile: TerminalColorProfile): TerminalPalette { + val background = Color(profile.background) + val foreground = Color(profile.foreground) + val accent = Color(profile.cursor) + val lightBackground = background.luminance() >= 0.5f + val onAccent = + if (contrastRatio(foreground, accent) >= contrastRatio(background, accent)) { + foreground + } else { + background + } + return TerminalPalette( + background = background, + surface = lerp(background, foreground, 0.06f), + raised = lerp(background, foreground, 0.12f), + line = lerp(background, foreground, 0.18f), + foreground = foreground, + muted = lerp(background, foreground, 0.65f), + accent = accent, + accentContainer = lerp(background, accent, 0.28f), + onAccent = onAccent, + error = if (lightBackground) Color(0xFFBA1A1A) else Color(0xFFFFB4AB), + onError = if (lightBackground) Color.White else Color(0xFF690005), + errorContainer = if (lightBackground) Color(0xFFFFDAD6) else Color(0xFF93000A), + onErrorContainer = if (lightBackground) Color(0xFF93000A) else Color(0xFFFFDAD6), + ) +} + +private fun contrastRatio( + first: Color, + second: Color, +): Float { + val lighter = maxOf(first.luminance(), second.luminance()) + val darker = minOf(first.luminance(), second.luminance()) + return (lighter + 0.05f) / (darker + 0.05f) +} + +private val LocalTerminalPalette = + staticCompositionLocalOf { terminalPalette(TerminalProfiles.udroid) } + // Terminal workspace: the management shell gives way to a focused instrument. -val UdroidTerminal = Color(0xFF11131F) -val UdroidTerminalSurface = Color(0xFF181B2A) -val UdroidTerminalRaised = Color(0xFF222638) -val UdroidTerminalLine = Color(0xFF2C3145) -val UdroidTerminalText = Color(0xFFE3E7EF) -val UdroidTerminalMuted = Color(0xFF9AA2B5) -val UdroidTerminalGreen = Color(0xFF43D292) +val UdroidTerminal: Color + @Composable get() = LocalTerminalPalette.current.background +val UdroidTerminalSurface: Color + @Composable get() = LocalTerminalPalette.current.surface +val UdroidTerminalRaised: Color + @Composable get() = LocalTerminalPalette.current.raised +val UdroidTerminalLine: Color + @Composable get() = LocalTerminalPalette.current.line +val UdroidTerminalText: Color + @Composable get() = LocalTerminalPalette.current.foreground +val UdroidTerminalMuted: Color + @Composable get() = LocalTerminalPalette.current.muted +val UdroidTerminalGreen: Color + @Composable get() = LocalTerminalPalette.current.accent object UdroidSpacing { val unit = 4 @@ -186,25 +255,42 @@ fun UdroidTheme(content: @Composable () -> Unit) { } @Composable -fun UdroidTerminalTheme(content: @Composable () -> Unit) { - MaterialTheme( - colorScheme = - darkColorScheme( - primary = UdroidTerminalGreen, - onPrimary = Color(0xFF052116), - primaryContainer = Color(0xFF164A39), - onPrimaryContainer = UdroidTerminalText, - surface = UdroidTerminalSurface, - surfaceVariant = UdroidTerminalRaised, - onSurface = UdroidTerminalText, - onSurfaceVariant = UdroidTerminalMuted, - background = UdroidTerminal, - onBackground = UdroidTerminalText, - outline = UdroidTerminalLine, - error = Color(0xFFFFB4AB), - ), - typography = Typography(), - shapes = UdroidShapes, - content = content, - ) +fun UdroidTerminalTheme( + profile: TerminalColorProfile = TerminalProfiles.udroid, + content: @Composable () -> Unit, +) { + val palette = terminalPalette(profile) + CompositionLocalProvider(LocalTerminalPalette provides palette) { + MaterialTheme( + colorScheme = + darkColorScheme( + primary = palette.accent, + onPrimary = palette.onAccent, + primaryContainer = palette.accentContainer, + onPrimaryContainer = palette.foreground, + surface = palette.surface, + surfaceVariant = palette.raised, + onSurface = palette.foreground, + onSurfaceVariant = palette.muted, + background = palette.background, + onBackground = palette.foreground, + surfaceDim = palette.background, + surfaceBright = palette.raised, + surfaceContainerLowest = palette.background, + surfaceContainerLow = palette.surface, + surfaceContainer = palette.surface, + surfaceContainerHigh = palette.raised, + surfaceContainerHighest = palette.raised, + outline = palette.line, + outlineVariant = palette.line, + error = palette.error, + onError = palette.onError, + errorContainer = palette.errorContainer, + onErrorContainer = palette.onErrorContainer, + ), + typography = Typography(), + shapes = UdroidShapes, + content = content, + ) + } } 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..efa0179 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt @@ -71,6 +71,8 @@ import org.randomcoder.udroid.runtime.GraphicsProfileCompatibilityProbe import org.randomcoder.udroid.runtime.GraphicsProfileSupport import org.randomcoder.udroid.runtime.InstalledRootfs import org.randomcoder.udroid.runtime.PROOT_DEFAULT_MOUNTS +import org.randomcoder.udroid.runtime.ProotCompatibilityConfiguration +import org.randomcoder.udroid.runtime.ProotCompatibilityConfigurationStore import org.randomcoder.udroid.runtime.ProotEnvironmentProfile import org.randomcoder.udroid.runtime.ProotEnvironmentProfileStore import org.randomcoder.udroid.runtime.ProotMountProfile @@ -129,6 +131,7 @@ fun LinuxSystemPage( val openDeveloperOptions = rememberDeveloperOptionsAction(onRefreshCapabilities) val mountProfileStore = remember(context) { ProotMountProfileStore(context) } val environmentProfileStore = remember(context) { ProotEnvironmentProfileStore(context) } + val compatibilityStore = remember(context) { ProotCompatibilityConfigurationStore(context) } var confirmation by remember(rootfs.name) { mutableStateOf(null) } @@ -140,6 +143,13 @@ fun LinuxSystemPage( runCatching { environmentProfileStore.load(rootfs.name) } .getOrDefault(ProotEnvironmentProfile()) } + var compatibility by remember(rootfs.name) { + mutableStateOf( + runCatching { compatibilityStore.load(rootfs.name) } + .getOrDefault(ProotCompatibilityConfiguration()), + ) + } + var compatibilityMessage by remember(rootfs.name) { mutableStateOf(null) } val selectedEnvironment = environments.firstOrNull { it.id == configuration.environmentId } ?: environments.firstOrNull() @@ -521,6 +531,29 @@ fun LinuxSystemPage( ) } + item(key = "compatibility-label") { + UdroidSectionLabel( + text = "PRoot compatibility", + modifier = Modifier.padding(top = 4.dp), + ) + } + item(key = "compatibility-settings") { + ProotCompatibilityPanel( + configuration = compatibility, + message = compatibilityMessage, + onChange = { updated -> + runCatching { compatibilityStore.save(rootfs.name, updated) } + .onSuccess { + compatibility = it + compatibilityMessage = "Saved · New launches use changes" + }.onFailure { + compatibilityMessage = + it.message ?: "Could not save PRoot compatibility settings" + } + }, + ) + } + item(key = "filesystem-label") { UdroidSectionLabel( text = "Storage", @@ -711,6 +744,46 @@ fun LinuxSystemPage( } +@Composable +private fun ProotCompatibilityPanel( + configuration: ProotCompatibilityConfiguration, + message: String?, + onChange: (ProotCompatibilityConfiguration) -> Unit, +) { + Surface( + color = Color.Transparent, + border = BorderStroke(1.dp, UdroidLine), + shape = MaterialTheme.shapes.medium, + ) { + Column { + SettingRow( + title = "System V IPC", + detail = "Emulate shared memory, semaphores, and message queues inside each PRoot launch.", + checked = configuration.sysvipcEnabled, + enabled = true, + onCheckedChange = { onChange(configuration.copy(sysvipcEnabled = it)) }, + ) + HorizontalDivider(color = UdroidLine) + SettingRow( + title = "memfd compatibility", + detail = "Use Android shared memory when software cannot create memfd objects directly.", + checked = configuration.memfdFallbackEnabled, + enabled = true, + onCheckedChange = { onChange(configuration.copy(memfdFallbackEnabled = it)) }, + ) + message?.let { + HorizontalDivider(color = UdroidLine) + Text( + text = it, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + color = UdroidMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + @Composable private fun EnvironmentProfilePanel( profile: ProotEnvironmentProfile, 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..6329b8d 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt @@ -1,12 +1,16 @@ package org.randomcoder.udroid.ui +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context -import android.graphics.Typeface +import android.content.Intent +import android.net.Uri import android.os.Build import android.util.Log import android.view.KeyEvent import android.view.MotionEvent import android.view.inputmethod.InputMethodManager +import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -37,6 +41,7 @@ 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.Palette import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -57,11 +62,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState 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.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight @@ -77,7 +83,15 @@ 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.TerminalColorProfile +import org.randomcoder.udroid.runtime.TerminalProfileState +import org.randomcoder.udroid.runtime.TerminalTypographyState +import org.randomcoder.udroid.runtime.TerminalTypographyStore +import org.randomcoder.udroid.runtime.TerminalTabCloseState import org.randomcoder.udroid.runtime.TerminalTabSnapshot +import org.randomcoder.udroid.runtime.MAX_TERMINAL_TITLE_CHARS +import org.randomcoder.udroid.runtime.TERMINAL_TEXT_SIZE_MAX_SP +import org.randomcoder.udroid.runtime.TERMINAL_TEXT_SIZE_MIN_SP import org.randomcoder.udroid.runtime.terminalDistroTitle import kotlin.math.roundToInt @@ -87,6 +101,12 @@ fun InteractiveTerminalPage( snapshot: RuntimeSnapshot, service: RuntimeSupervisorService?, installedRootfses: List, + profileState: TerminalProfileState, + typographyState: TerminalTypographyState, + onSelectProfile: (String) -> Unit, + onSaveProfile: (TerminalColorProfile) -> Unit, + onDeleteProfile: (String) -> Unit, + onTypographyChanged: (TerminalTypographyState) -> Unit, onStart: () -> Unit, onStop: () -> Unit, onExit: () -> Unit, @@ -97,8 +117,10 @@ fun InteractiveTerminalPage( var stopRequested by remember(session) { mutableStateOf(false) } var tabError by remember { mutableStateOf(null) } var renamingTab by remember { mutableStateOf(null) } + var pendingCloseTabId by remember { mutableStateOf(null) } var creatingTab by remember { mutableStateOf(false) } var showDistroPicker by remember { mutableStateOf(false) } + var showAppearance by remember { mutableStateOf(false) } val stopping = stopRequested || snapshot.phase == RuntimePhase.STOPPING fun createTab(rootfsName: String?) { @@ -117,6 +139,26 @@ fun InteractiveTerminalPage( } } + fun closeTab(id: String) { + if (service?.closeTerminalTab(id) != true) { + tabError = "Could not close terminal" + } + } + + if (showAppearance) { + TerminalSettingsPage( + modifier = modifier, + state = profileState, + typographyState = typographyState, + onBack = { showAppearance = false }, + onSelectProfile = onSelectProfile, + onSaveProfile = onSaveProfile, + onDeleteProfile = onDeleteProfile, + onTypographyChanged = onTypographyChanged, + ) + return + } + Column( modifier = modifier @@ -130,6 +172,7 @@ fun InteractiveTerminalPage( stopping = stopping, onExit = onExit, onChooseDistro = { showDistroPicker = true }, + onShowAppearance = { showAppearance = true }, onStop = { stopRequested = true onStop() @@ -141,13 +184,21 @@ fun InteractiveTerminalPage( tabs = tabs, onSelect = { id -> service?.selectTerminalTab(id) }, onRename = { tab -> renamingTab = tab }, - onClose = { id -> service?.closeTerminalTab(id) }, + onFollowTitle = { id -> service?.followTerminalShellTitle(id) }, + onClose = { id -> + if (tabs.firstOrNull { it.id == id }?.commandRunning == true) { + pendingCloseTabId = id + } else { + closeTab(id) + } + }, creatingTab = creatingTab, onClone = { createTab(activeTab?.rootfsName) }, ) } - tabError?.let { message -> + val closeError = tabs.firstOrNull { it.closeState == TerminalTabCloseState.FAILED }?.closeError + (tabError ?: closeError)?.let { message -> Text( text = message, modifier = @@ -177,6 +228,8 @@ fun InteractiveTerminalPage( modifier = Modifier.weight(1f), service = service, session = session, + typography = typographyState, + onTypographyChanged = onTypographyChanged, ) } } @@ -196,6 +249,38 @@ fun InteractiveTerminalPage( ) } + tabs.firstOrNull { it.id == pendingCloseTabId }?.let { tab -> + val stopsLinux = tabs.size == 1 + AlertDialog( + onDismissRequest = { pendingCloseTabId = null }, + title = { Text(if (stopsLinux) "Stop Linux?" else "Close tab?") }, + text = { + Text( + if (stopsLinux) { + "A command is still running. Stopping Linux will end it." + } else { + "A command is still running in ${tab.title}. Closing the tab will end it." + }, + ) + }, + confirmButton = { + Button( + onClick = { + pendingCloseTabId = null + closeTab(tab.id) + }, + ) { + Text(if (stopsLinux) "Stop Linux" else "Close tab") + } + }, + dismissButton = { + TextButton(onClick = { pendingCloseTabId = null }) { + Text("Cancel") + } + }, + ) + } + if (showDistroPicker) { TerminalDistroPicker( installedRootfses = installedRootfses, @@ -208,7 +293,6 @@ fun InteractiveTerminalPage( ) } } - @Composable private fun TerminalSessionBar( snapshot: RuntimeSnapshot, @@ -217,9 +301,13 @@ private fun TerminalSessionBar( stopping: Boolean, onExit: () -> Unit, onChooseDistro: () -> Unit, + onShowAppearance: () -> Unit, onStop: () -> Unit, ) { - val running = session?.isRunning == true && !stopping + val tabClosing = + activeTab?.closeState == TerminalTabCloseState.CLOSING || + activeTab?.closeState == TerminalTabCloseState.FORCE_CLOSING + val running = session?.isRunning == true && !stopping && !tabClosing Row( modifier = Modifier @@ -267,6 +355,12 @@ private fun TerminalSessionBar( Text( when { stopping -> "Stopping terminal…" + activeTab?.closeState == TerminalTabCloseState.CLOSING -> + "Closing terminal…" + activeTab?.closeState == TerminalTabCloseState.FORCE_CLOSING -> + "Force closing terminal…" + activeTab?.closeState == TerminalTabCloseState.FAILED -> + activeTab.closeError ?: "Could not close terminal" running -> "root • ${Build.SUPPORTED_ABIS.firstOrNull().orEmpty()}" + " • PID ${session?.pid}" @@ -309,15 +403,22 @@ private fun TerminalSessionBar( } else { Spacer(Modifier.width(48.dp)) } + IconButton(onClick = onShowAppearance) { + Icon( + imageVector = Icons.Outlined.Palette, + contentDescription = "Terminal appearance", + tint = UdroidTerminalMuted, + ) + } } } - @Composable @OptIn(ExperimentalFoundationApi::class) private fun TerminalTabsRow( tabs: List, onSelect: (String) -> Unit, onRename: (TerminalTabSnapshot) -> Unit, + onFollowTitle: (String) -> Unit, onClose: (String) -> Unit, creatingTab: Boolean, onClone: () -> Unit, @@ -360,7 +461,9 @@ private fun TerminalTabsRow( tab = tab, onSelect = { onSelect(tab.id) }, onRename = { onRename(tab) }, + onFollowTitle = { onFollowTitle(tab.id) }, onClose = { onClose(tab.id) }, + stopsLinux = tabs.size == 1, ) } } @@ -401,15 +504,20 @@ private fun TerminalTabChip( tab: TerminalTabSnapshot, onSelect: () -> Unit, onRename: () -> Unit, + onFollowTitle: () -> Unit, onClose: () -> Unit, + stopsLinux: Boolean, ) { var menuExpanded by remember { mutableStateOf(false) } + val closing = + tab.closeState == TerminalTabCloseState.CLOSING || + tab.closeState == TerminalTabCloseState.FORCE_CLOSING Surface( modifier = modifier .width(144.dp) .height(46.dp) - .clickable(onClick = onSelect), + .clickable(enabled = !closing, onClick = onSelect), color = if (tab.active) UdroidTerminal else UdroidTerminalRaised, shape = RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp), border = @@ -423,54 +531,97 @@ private fun TerminalTabChip( 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, - ) + if (closing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = UdroidTerminalGreen, + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Outlined.Terminal, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = + if (tab.closeState == TerminalTabCloseState.FAILED) { + MaterialTheme.colorScheme.error + } else if (tab.active) { + UdroidTerminalGreen + } else { + UdroidTerminalMuted + }, + ) + } Spacer(Modifier.width(7.dp)) Text( - text = tab.title, + text = + when (tab.closeState) { + TerminalTabCloseState.CLOSING -> "${tab.title} · closing" + TerminalTabCloseState.FORCE_CLOSING -> "${tab.title} · force closing" + TerminalTabCloseState.FAILED -> "${tab.title} · close failed" + TerminalTabCloseState.OPEN -> 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() - }, - ) + if (!closing) { + 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() + }, + ) + if (tab.hasCustomTitle) { + DropdownMenuItem( + text = { Text("Follow shell title") }, + onClick = { + menuExpanded = false + onFollowTitle() + }, + ) + } + DropdownMenuItem( + text = { + Text( + when { + tab.closeState == TerminalTabCloseState.FAILED -> "Retry close" + stopsLinux -> "Stop Linux" + else -> "Close tab" + }, + ) + }, + leadingIcon = { + Icon( + if (stopsLinux) Icons.Filled.StopCircle else Icons.Outlined.Close, + contentDescription = null, + ) + }, + onClick = { + menuExpanded = false + onClose() + }, + ) + } } } } @@ -588,7 +739,7 @@ private fun RenameTerminalDialog( text = { OutlinedTextField( value = title, - onValueChange = { title = it.take(40) }, + onValueChange = { title = it.take(MAX_TERMINAL_TITLE_CHARS) }, label = { Text("Terminal name") }, singleLine = true, ) @@ -733,40 +884,44 @@ private fun LiveTerminal( modifier: Modifier = Modifier, service: RuntimeSupervisorService, session: TerminalSession, + typography: TerminalTypographyState, + onTypographyChanged: (TerminalTypographyState) -> Unit, ) { val context = LocalContext.current val density = LocalDensity.current + val terminalBackground = UdroidTerminal.toArgb() val modifiers = remember { TerminalModifierState() } - val preferences = - remember(context) { - context.applicationContext.getSharedPreferences( - TERMINAL_PREFERENCES, - Context.MODE_PRIVATE, - ) - } - val initialTextSize = remember(density) { - preferences.getInt( - KEY_TEXT_SIZE_PX, - with(density) { 15.sp.toPx().roundToInt() }, - ).coerceIn(MIN_TEXT_SIZE_PX.toInt(), MAX_TEXT_SIZE_PX.toInt()) - } + val typographyStore = remember(context) { TerminalTypographyStore(context) } + val typeface = remember(typography.selectedFont) { typographyStore.loadTypeface(typography.selectedFont) } + val textSizePx = with(density) { typography.textSizeSp.sp.toPx().roundToInt() } + val minimumTextSizePx = with(density) { TERMINAL_TEXT_SIZE_MIN_SP.sp.toPx() } + val maximumTextSizePx = with(density) { TERMINAL_TEXT_SIZE_MAX_SP.sp.toPx() } + val currentTypography by rememberUpdatedState(typography) + val currentOnTypographyChanged by rememberUpdatedState(onTypographyChanged) + var pendingLink by remember { mutableStateOf(null) } val client = - remember { + remember(context, density) { UdroidTerminalViewClient( context = context, modifiers = modifiers, - initialTextSize = initialTextSize, + initialTextSize = textSizePx, + minimumTextSize = minimumTextSizePx, + maximumTextSize = maximumTextSizePx, + onLinkTapped = { pendingLink = it }, onTextSizeChanged = { - preferences.edit().putInt(KEY_TEXT_SIZE_PX, it).apply() + currentOnTypographyChanged( + currentTypography.copy( + textSizeSp = with(density) { it.toSp().value }, + ), + ) }, ) } val terminalView = - remember { + remember(context) { TerminalView(context, null).apply { - setBackgroundColor(android.graphics.Color.rgb(17, 19, 31)) - setTextSize(initialTextSize) - setTypeface(Typeface.MONOSPACE) + setBackgroundColor(terminalBackground) + setTextAppearance(textSizePx, typeface, typography.lineSpacing) isFocusable = true isFocusableInTouchMode = true setTerminalViewClient(client) @@ -774,9 +929,16 @@ private fun LiveTerminal( } } + LaunchedEffect(terminalView, typeface, textSizePx, typography.lineSpacing, typography.cursorBlinkRateMs) { + client.updateTextSize(textSizePx) + terminalView.setTextAppearance(textSizePx, typeface, typography.lineSpacing) + client.updateCursorBlinkRate(typography.cursorBlinkRateMs) + } + DisposableEffect(service, terminalView) { service.attachTerminalView(terminalView) onDispose { + client.stopCursorBlinking() service.detachTerminalView(terminalView) } } @@ -785,6 +947,7 @@ private fun LiveTerminal( AndroidView( factory = { terminalView }, update = { view -> + view.setBackgroundColor(terminalBackground) if (view.mTermSession !== session) { view.attachSession(session) } @@ -800,8 +963,49 @@ private fun LiveTerminal( write = service::writeToTerminal, ) } + pendingLink?.let { uri -> + AlertDialog( + onDismissRequest = { pendingLink = null }, + title = { Text("Open link?") }, + text = { Text(uri) }, + confirmButton = { + TextButton( + onClick = { + pendingLink = null + if (!openTerminalLink(context, uri)) { + Toast.makeText(context, "This link type cannot be opened", Toast.LENGTH_SHORT).show() + } + }, + ) { Text("Open") } + }, + dismissButton = { + TextButton( + onClick = { + context.getSystemService(ClipboardManager::class.java) + .setPrimaryClip(ClipData.newPlainText("Terminal link", uri)) + pendingLink = null + Toast.makeText(context, "Link copied", Toast.LENGTH_SHORT).show() + }, + ) { Text("Copy") } + }, + ) + } } +internal fun openTerminalLink( + context: Context, + value: String, +): Boolean { + val uri = Uri.parse(value) + if (!terminalLinkCanOpen(uri.scheme)) return false + return runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + }.isSuccess +} + +internal fun terminalLinkCanOpen(scheme: String?): Boolean = + scheme.equals("http", true) || scheme.equals("https", true) || scheme.equals("mailto", true) + @Composable private fun ExtraKeysRow( modifiers: TerminalModifierState, @@ -855,7 +1059,7 @@ private fun TerminalKey( .background( color = if (active) { - Color(0xFF164A39) + MaterialTheme.colorScheme.primaryContainer } else { UdroidTerminalRaised }, @@ -881,17 +1085,41 @@ private class UdroidTerminalViewClient( private val context: Context, private val modifiers: TerminalModifierState, initialTextSize: Int, + private val minimumTextSize: Float, + private val maximumTextSize: Float, + private val onLinkTapped: (String) -> Unit, private val onTextSizeChanged: (Int) -> Unit, ) : TerminalViewClient { private var terminalView: TerminalView? = null private var textSize = initialTextSize.toFloat() + private var cursorBlinkRate = 0 fun attach(view: TerminalView) { terminalView = view } + fun updateTextSize(textSize: Int) { + this.textSize = textSize.toFloat() + } + + fun updateCursorBlinkRate(rate: Int) { + cursorBlinkRate = rate + applyCursorBlinking() + } + + fun stopCursorBlinking() { + terminalView?.setTerminalCursorBlinkerState(false, true) + } + + private fun applyCursorBlinking() { + terminalView?.let { view -> + view.setTerminalCursorBlinkerRate(cursorBlinkRate) + view.setTerminalCursorBlinkerState(cursorBlinkRate > 0, true) + } + } + override fun onScale(scale: Float): Float { - textSize = (textSize * scale).coerceIn(MIN_TEXT_SIZE_PX, MAX_TEXT_SIZE_PX) + textSize = (textSize * scale).coerceIn(minimumTextSize, maximumTextSize) val roundedTextSize = textSize.roundToInt() terminalView?.setTextSize(roundedTextSize) onTextSizeChanged(roundedTextSize) @@ -900,6 +1128,11 @@ private class UdroidTerminalViewClient( override fun onSingleTapUp(event: MotionEvent) { terminalView?.let { view -> + val (column, row) = view.getColumnAndRow(event, true) + view.currentSession?.emulator?.getHyperlinkUriAt(row, column)?.let { + onLinkTapped(it) + return + } view.requestFocus() context.getSystemService(InputMethodManager::class.java) .showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) @@ -949,7 +1182,9 @@ private class UdroidTerminalViewClient( return false } - override fun onEmulatorSet() = Unit + override fun onEmulatorSet() { + applyCursorBlinking() + } override fun logError( tag: String, @@ -1001,8 +1236,3 @@ private class UdroidTerminalViewClient( Log.e(tag, error.message, error) } } - -private const val TERMINAL_PREFERENCES = "terminal-view" -private const val KEY_TEXT_SIZE_PX = "text-size-px" -private const val MIN_TEXT_SIZE_PX = 20f -private const val MAX_TEXT_SIZE_PX = 64f diff --git a/app/src/main/java/org/randomcoder/udroid/ui/TerminalSettingsPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/TerminalSettingsPage.kt new file mode 100644 index 0000000..746b126 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/ui/TerminalSettingsPage.kt @@ -0,0 +1,1283 @@ +package org.randomcoder.udroid.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Typeface +import android.net.Uri +import android.provider.OpenableColumns +import android.util.TypedValue +import android.view.Gravity +import android.widget.TextView +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.TextFields +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import org.randomcoder.udroid.runtime.TerminalColorProfile +import org.randomcoder.udroid.runtime.TerminalCursorStyle +import org.randomcoder.udroid.runtime.TerminalFonts +import org.randomcoder.udroid.runtime.TerminalFontProfile +import org.randomcoder.udroid.runtime.TerminalProfileState +import org.randomcoder.udroid.runtime.TerminalTypographyState +import org.randomcoder.udroid.runtime.TerminalTypographyStore +import org.randomcoder.udroid.runtime.TERMINAL_CURSOR_BLINK_RATE_DEFAULT_MS +import org.randomcoder.udroid.runtime.TERMINAL_CURSOR_BLINK_RATE_MAX_MS +import org.randomcoder.udroid.runtime.TERMINAL_CURSOR_BLINK_RATE_MIN_MS +import org.randomcoder.udroid.runtime.TERMINAL_LINE_SPACING_MAX +import org.randomcoder.udroid.runtime.TERMINAL_LINE_SPACING_MIN +import org.randomcoder.udroid.runtime.TERMINAL_TEXT_SIZE_MAX_SP +import org.randomcoder.udroid.runtime.TERMINAL_TEXT_SIZE_MIN_SP +import org.randomcoder.udroid.runtime.exportTermuxProfile +import org.randomcoder.udroid.runtime.formatTerminalColor +import org.randomcoder.udroid.runtime.importTerminalProfile +import org.randomcoder.udroid.runtime.parseTerminalColor +import java.util.Locale +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt + +private enum class TerminalAppearancePage { + HOME, + COLORS, + TYPOGRAPHY, + FONTS, +} + +@Composable +internal fun TerminalSettingsPage( + modifier: Modifier = Modifier, + state: TerminalProfileState, + typographyState: TerminalTypographyState, + onBack: () -> Unit, + onSelectProfile: (String) -> Unit, + onSaveProfile: (TerminalColorProfile) -> Unit, + onDeleteProfile: (String) -> Unit, + onTypographyChanged: (TerminalTypographyState) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val typographyStore = remember(context) { TerminalTypographyStore(context) } + var editingProfile by remember { mutableStateOf(null) } + var page by remember { mutableStateOf(TerminalAppearancePage.HOME) } + var pendingDeleteId by remember { mutableStateOf(null) } + var fileMessage by remember { mutableStateOf(null) } + var searchQuery by remember { mutableStateOf("") } + val importLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + scope.launch { + val result = + withContext(Dispatchers.IO) { + runCatching { + val fileName = context.displayName(uri) ?: "Imported profile" + importTerminalProfile(fileName, context.readPaletteText(uri)).getOrThrow() + } + } + result.onSuccess { profile -> + onSaveProfile(profile.copy(id = UUID.randomUUID().toString())) + fileMessage = "Imported ${profile.name}" + }.onFailure { error -> + fileMessage = "Could not import palette: ${error.message ?: "invalid file"}" + } + } + } + } + val exportLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri != null) { + scope.launch { + val profile = state.selectedProfile + val result = + withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openOutputStream(uri)?.bufferedWriter()?.use { + it.write(exportTermuxProfile(profile)) + } ?: error("Could not open file") + } + } + result.onSuccess { + fileMessage = "Exported ${profile.name}" + }.onFailure { error -> + fileMessage = "Could not export palette: ${error.message ?: "write failed"}" + } + } + } + } + val editing = editingProfile + BackHandler { + when { + editing != null -> editingProfile = null + page == TerminalAppearancePage.FONTS -> page = TerminalAppearancePage.TYPOGRAPHY + page != TerminalAppearancePage.HOME -> page = TerminalAppearancePage.HOME + else -> onBack() + } + } + if (editing != null) { + TerminalProfileEditor( + modifier = modifier, + initial = editing, + onBack = { editingProfile = null }, + onSave = { + onSaveProfile(it) + editingProfile = null + }, + ) + return + } + if (page == TerminalAppearancePage.TYPOGRAPHY) { + TerminalTypographySettingsPage( + modifier = modifier, + state = typographyState, + colorProfile = state.selectedProfile, + store = typographyStore, + onBack = { page = TerminalAppearancePage.HOME }, + onChooseFont = { page = TerminalAppearancePage.FONTS }, + onChanged = onTypographyChanged, + ) + return + } + if (page == TerminalAppearancePage.FONTS) { + TerminalFontPickerPage( + modifier = modifier, + state = typographyState, + store = typographyStore, + onBack = { page = TerminalAppearancePage.TYPOGRAPHY }, + onChanged = onTypographyChanged, + ) + return + } + if (page == TerminalAppearancePage.HOME) { + TerminalAppearanceHome( + modifier = modifier, + colorProfile = state.selectedProfile, + typography = typographyState, + onBack = onBack, + onOpenColors = { page = TerminalAppearancePage.COLORS }, + onOpenTypography = { page = TerminalAppearancePage.TYPOGRAPHY }, + ) + return + } + val profiles = filterTerminalProfiles(state.profiles, searchQuery) + + Column( + modifier = + modifier + .fillMaxSize() + .background(UdroidTerminal), + ) { + TerminalSettingsHeader(title = "Colors", onBack = { page = TerminalAppearancePage.HOME }) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Text( + "Profiles set the terminal, cursor, ANSI colors, and surrounding controls.", + color = UdroidTerminalMuted, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + importLauncher.launch(arrayOf("text/*", "application/xml", "application/octet-stream")) + }, + modifier = Modifier.weight(1f), + ) { + Text("Import") + } + OutlinedButton( + onClick = { + val name = state.selectedProfile.name.replace(INVALID_FILE_NAME, "-") + exportLauncher.launch("$name.properties") + }, + modifier = Modifier.weight(1f), + ) { + Text("Export selected") + } + } + fileMessage?.let { message -> + Spacer(Modifier.height(8.dp)) + Text(message, color = UdroidTerminalMuted, style = MaterialTheme.typography.bodySmall) + } + } + item(key = "profile-search") { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search color profiles") }, + leadingIcon = { + Icon(Icons.Rounded.Search, contentDescription = null) + }, + trailingIcon = + if (searchQuery.isBlank()) { + null + } else { + { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Rounded.Close, contentDescription = "Clear search") + } + } + }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + } + if (profiles.isEmpty()) { + item(key = "no-profile-results") { + Text( + "No color profiles match “$searchQuery”.", + modifier = Modifier.padding(vertical = 12.dp), + color = UdroidTerminalMuted, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + items(profiles, key = TerminalColorProfile::id) { profile -> + TerminalProfileCard( + profile = profile, + selected = profile.id == state.selectedProfile.id, + builtIn = profile.id.startsWith("builtin:"), + onSelect = { onSelectProfile(profile.id) }, + onEdit = { + editingProfile = + if (profile.id.startsWith("builtin:")) { + profile.copy( + id = UUID.randomUUID().toString(), + name = "${profile.name} custom", + ) + } else { + profile + } + }, + pendingDelete = pendingDeleteId == profile.id, + onDelete = { pendingDeleteId = profile.id }, + onConfirmDelete = { + onDeleteProfile(profile.id) + pendingDeleteId = null + }, + onCancelDelete = { pendingDeleteId = null }, + ) + } + item { + OutlinedButton( + onClick = { + editingProfile = + state.selectedProfile.copy( + id = UUID.randomUUID().toString(), + name = "New profile", + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Outlined.Add, contentDescription = null) + Spacer(Modifier.size(8.dp)) + Text("New profile") + } + } + } + } +} + +@Composable +private fun TerminalAppearanceHome( + modifier: Modifier, + colorProfile: TerminalColorProfile, + typography: TerminalTypographyState, + onBack: () -> Unit, + onOpenColors: () -> Unit, + onOpenTypography: () -> Unit, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(UdroidTerminal), + ) { + TerminalSettingsHeader(title = "Terminal appearance", onBack = onBack) + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "Choose what you want to customize.", + color = UdroidTerminalMuted, + style = MaterialTheme.typography.bodyMedium, + ) + TerminalAppearanceEntry( + icon = { Icon(Icons.Outlined.Palette, contentDescription = null, tint = UdroidTerminalGreen) }, + title = "Colors", + detail = colorProfile.name, + onClick = onOpenColors, + ) + TerminalAppearanceEntry( + icon = { Icon(Icons.Outlined.TextFields, contentDescription = null, tint = UdroidTerminalGreen) }, + title = "Font & text", + detail = "${typography.selectedFont.name} · ${typography.textSizeSp.formatValue()} sp", + onClick = onOpenTypography, + ) + } + } +} + +@Composable +private fun TerminalAppearanceEntry( + icon: @Composable () -> Unit, + title: String, + detail: String, + onClick: () -> Unit, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + color = UdroidTerminalSurface, + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, UdroidTerminalLine), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(42.dp) + .background(UdroidTerminalRaised, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + icon() + } + Spacer(Modifier.size(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(title, color = UdroidTerminalText, fontWeight = FontWeight.SemiBold) + Text(detail, color = UdroidTerminalMuted, style = MaterialTheme.typography.bodySmall) + } + Icon( + Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = "Open $title settings", + tint = UdroidTerminalMuted, + ) + } + } +} + +@Composable +private fun TerminalSettingsHeader( + title: String, + onBack: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(58.dp) + .background(UdroidTerminalSurface) + .padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + Text( + title, + color = UdroidTerminalText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun TerminalTypographySettingsPage( + modifier: Modifier, + state: TerminalTypographyState, + colorProfile: TerminalColorProfile, + store: TerminalTypographyStore, + onBack: () -> Unit, + onChooseFont: () -> Unit, + onChanged: (TerminalTypographyState) -> Unit, +) { + val typeface = remember(state.selectedFont) { store.loadTypeface(state.selectedFont) } + + Column( + modifier = + modifier + .fillMaxSize() + .background(UdroidTerminal), + ) { + TerminalSettingsHeader(title = "Font & text", onBack = onBack) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + TerminalTypographyPreview( + state = state, + profile = colorProfile, + typeface = typeface, + ) + } + item { + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onChooseFont), + color = UdroidTerminalSurface, + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, UdroidTerminalLine), + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Font", color = UdroidTerminalText, fontWeight = FontWeight.SemiBold) + Text( + state.selectedFont.name, + color = UdroidTerminalMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + Icon( + Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = "Choose terminal font", + tint = UdroidTerminalMuted, + ) + } + } + } + item { + Surface( + color = UdroidTerminalSurface, + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, UdroidTerminalLine), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TerminalSettingSlider( + label = "Text size", + valueLabel = "${state.textSizeSp.formatValue()} sp", + value = state.textSizeSp, + range = TERMINAL_TEXT_SIZE_MIN_SP..TERMINAL_TEXT_SIZE_MAX_SP, + steps = (TERMINAL_TEXT_SIZE_MAX_SP - TERMINAL_TEXT_SIZE_MIN_SP).roundToInt() - 1, + onValueChange = { onChanged(state.copy(textSizeSp = it)) }, + ) + HorizontalDivider(color = UdroidTerminalLine) + TerminalSettingSlider( + label = "Line height", + valueLabel = "${(state.lineSpacing * 100).roundToInt()}%", + value = state.lineSpacing, + range = TERMINAL_LINE_SPACING_MIN..TERMINAL_LINE_SPACING_MAX, + steps = 15, + onValueChange = { onChanged(state.copy(lineSpacing = it)) }, + ) + } + } + } + item { + Surface( + color = UdroidTerminalSurface, + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, UdroidTerminalLine), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Cursor", color = UdroidTerminalText, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TerminalCursorStyle.entries.forEach { style -> + FilterChip( + selected = state.cursorStyle == style, + onClick = { onChanged(state.copy(cursorStyle = style)) }, + label = { Text(style.displayName()) }, + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("Blink cursor", color = UdroidTerminalText) + Text( + if (state.cursorBlinkRateMs == 0) "Off" else "Lower is faster", + color = UdroidTerminalMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + Switch( + checked = state.cursorBlinkRateMs > 0, + onCheckedChange = { enabled -> + onChanged( + state.copy( + cursorBlinkRateMs = + if (enabled) TERMINAL_CURSOR_BLINK_RATE_DEFAULT_MS else 0, + ), + ) + }, + ) + } + if (state.cursorBlinkRateMs > 0) { + TerminalSettingSlider( + label = "Blink interval", + valueLabel = "${state.cursorBlinkRateMs} ms", + value = state.cursorBlinkRateMs.toFloat(), + range = + TERMINAL_CURSOR_BLINK_RATE_MIN_MS.toFloat().. + TERMINAL_CURSOR_BLINK_RATE_MAX_MS.toFloat(), + steps = 18, + onValueChange = { + onChanged(state.copy(cursorBlinkRateMs = (it / 100).roundToInt() * 100)) + }, + ) + } + } + } + } + item { + OutlinedButton( + onClick = { + onChanged( + state.copy( + selectedFontId = TerminalFonts.system.id, + textSizeSp = 15f, + lineSpacing = 1f, + cursorStyle = TerminalCursorStyle.BLOCK, + cursorBlinkRateMs = 0, + ), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Reset font & text") + } + } + } + } +} + +@Composable +private fun TerminalFontPickerPage( + modifier: Modifier, + state: TerminalTypographyState, + store: TerminalTypographyStore, + onBack: () -> Unit, + onChanged: (TerminalTypographyState) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var searchQuery by remember { mutableStateOf("") } + var message by remember { mutableStateOf(null) } + val fontLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + if (uris.isNotEmpty()) { + scope.launch { + val results = + withContext(Dispatchers.IO) { + uris.map { uri -> + val name = context.displayName(uri) ?: "Imported font.ttf" + store.importFont(uri, name) + } + } + val imported = results.mapNotNull(Result::getOrNull) + if (imported.isNotEmpty()) { + onChanged( + state.copy( + selectedFontId = imported.last().id, + importedFonts = state.importedFonts + imported, + ), + ) + } + val failed = results.size - imported.size + message = + when { + imported.size == 1 && failed == 0 -> "Imported ${imported.single().name}" + imported.isNotEmpty() && failed == 0 -> "Imported ${imported.size} fonts" + imported.isNotEmpty() -> "Imported ${imported.size} fonts; $failed failed" + else -> { + val reason = + results.firstNotNullOfOrNull { it.exceptionOrNull()?.message } + ?: "invalid files" + "Could not import fonts: $reason" + } + } + } + } + } + val fonts = state.fonts.filter { it.name.contains(searchQuery, ignoreCase = true) } + + Column( + modifier = + modifier + .fillMaxSize() + .background(UdroidTerminal), + ) { + TerminalSettingsHeader(title = "Fonts", onBack = onBack) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Use a monospaced font to keep terminal columns aligned.", + modifier = Modifier.weight(1f), + color = UdroidTerminalMuted, + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.size(12.dp)) + OutlinedButton( + onClick = { + fontLauncher.launch( + arrayOf( + "font/ttf", + "font/otf", + "application/x-font-ttf", + "application/x-font-opentype", + "application/octet-stream", + ), + ) + }, + ) { + Text("Import") + } + } + message?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = UdroidTerminalMuted, style = MaterialTheme.typography.bodySmall) + } + } + item { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search fonts") }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, + trailingIcon = + if (searchQuery.isBlank()) { + null + } else { + { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Rounded.Close, contentDescription = "Clear search") + } + } + }, + singleLine = true, + ) + } + if (fonts.isEmpty()) { + item { + Text( + "No fonts match “$searchQuery”.", + modifier = Modifier.padding(vertical = 12.dp), + color = UdroidTerminalMuted, + ) + } + } + items(fonts, key = TerminalFontProfile::id) { font -> + val typeface = remember(font) { store.loadTypeface(font) } + TerminalFontRow( + font = font, + typeface = typeface, + selected = font.id == state.selectedFontId, + onSelect = { onChanged(state.copy(selectedFontId = font.id)) }, + onDelete = + if (font.fileName == null) { + null + } else { + { + store.delete(font) + onChanged( + state.copy( + selectedFontId = + if (state.selectedFontId == font.id) { + TerminalFonts.system.id + } else { + state.selectedFontId + }, + importedFonts = state.importedFonts.filterNot { it.id == font.id }, + ), + ) + } + }, + ) + } + } + } +} + +@Composable +@SuppressLint("SetTextI18n") +private fun TerminalTypographyPreview( + state: TerminalTypographyState, + profile: TerminalColorProfile, + typeface: Typeface, +) { + Surface( + color = Color(profile.background), + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, Color(profile.foreground).copy(alpha = 0.22f)), + ) { + val cursor = + when (state.cursorStyle) { + TerminalCursorStyle.BLOCK -> "█" + TerminalCursorStyle.BAR -> "│" + TerminalCursorStyle.UNDERLINE -> "_" + } + AndroidView( + factory = { context -> + TextView(context).apply { + gravity = Gravity.CENTER_VERTICAL + includeFontPadding = false + val padding = (16 * resources.displayMetrics.density).roundToInt() + setPadding(padding, padding, padding, padding) + } + }, + update = { view -> + view.text = "root@linux:~$ printf 'Hello'\nHello\n$cursor" + view.setTextColor(profile.foreground) + view.setTextSize(TypedValue.COMPLEX_UNIT_SP, state.textSizeSp) + view.typeface = typeface + view.setLineSpacing(0f, state.lineSpacing) + }, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 116.dp), + ) + } +} + +@Composable +@SuppressLint("SetTextI18n") +private fun TerminalFontRow( + font: TerminalFontProfile, + typeface: Typeface, + selected: Boolean, + onSelect: () -> Unit, + onDelete: (() -> Unit)?, +) { + val sampleTextColor = UdroidTerminalText.toArgb() + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onSelect), + color = UdroidTerminalSurface, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, if (selected) UdroidTerminalGreen else UdroidTerminalLine), + ) { + Row( + modifier = Modifier.padding(start = 4.dp, end = 6.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onSelect) + Column(modifier = Modifier.weight(1f)) { + Text(font.name, color = UdroidTerminalText, fontWeight = FontWeight.Medium) + Text( + if (font.fileName == null) "Included" else "Imported", + color = UdroidTerminalMuted, + style = MaterialTheme.typography.labelSmall, + ) + AndroidView( + factory = { context -> + TextView(context).apply { + includeFontPadding = false + text = "Aa Bb Cc 0123 ~/ {}[]" + setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) + setTextColor(sampleTextColor) + } + }, + update = { it.typeface = typeface }, + modifier = Modifier.fillMaxWidth(), + ) + } + if (onDelete != null) { + IconButton(onClick = onDelete) { + Icon( + Icons.Outlined.Delete, + contentDescription = "Delete ${font.name}", + tint = UdroidTerminalMuted, + ) + } + } + } + } +} + +@Composable +private fun TerminalSettingSlider( + label: String, + valueLabel: String, + value: Float, + range: ClosedFloatingPointRange, + steps: Int, + onValueChange: (Float) -> Unit, +) { + Column { + Row { + Text(label, modifier = Modifier.weight(1f), color = UdroidTerminalText) + Text(valueLabel, color = UdroidTerminalMuted, style = MaterialTheme.typography.labelMedium) + } + Slider( + value = value, + onValueChange = onValueChange, + valueRange = range, + steps = steps, + ) + } +} + +private fun TerminalCursorStyle.displayName(): String = + when (this) { + TerminalCursorStyle.BLOCK -> "Block" + TerminalCursorStyle.BAR -> "Bar" + TerminalCursorStyle.UNDERLINE -> "Underline" + } + +private fun Float.formatValue(): String = + if (this == roundToInt().toFloat()) roundToInt().toString() else String.format(Locale.US, "%.1f", this) + +@Composable +private fun TerminalProfileCard( + profile: TerminalColorProfile, + selected: Boolean, + builtIn: Boolean, + onSelect: () -> Unit, + onEdit: () -> Unit, + pendingDelete: Boolean, + onDelete: () -> Unit, + onConfirmDelete: () -> Unit, + onCancelDelete: () -> Unit, +) { + var menuExpanded by remember(profile.id) { mutableStateOf(false) } + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onSelect), + color = UdroidTerminalSurface, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, if (selected) UdroidTerminalGreen else UdroidTerminalLine), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(profile.name, color = UdroidTerminalText, fontWeight = FontWeight.SemiBold) + Text( + if (builtIn) "Built-in profile" else "Custom profile", + color = UdroidTerminalMuted, + style = MaterialTheme.typography.labelMedium, + ) + } + if (selected) { + Icon(Icons.Outlined.Check, contentDescription = "Selected", tint = UdroidTerminalGreen) + } + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = "Actions for ${profile.name}", + tint = UdroidTerminalMuted, + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(if (builtIn) "Customize" else "Edit") }, + onClick = { + menuExpanded = false + onEdit() + }, + ) + if (!builtIn) { + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + menuExpanded = false + onDelete() + }, + ) + } + } + } + } + CompactTerminalProfilePreview(profile) + if (pendingDelete) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Delete this profile?", modifier = Modifier.weight(1f), color = UdroidTerminalText) + TextButton(onClick = onCancelDelete) { Text("Cancel") } + TextButton(onClick = onConfirmDelete) { Text("Delete") } + } + } + } + } +} + +@Composable +private fun CompactTerminalProfilePreview(profile: TerminalColorProfile) { + Surface( + color = Color(profile.background), + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, Color(profile.foreground).copy(alpha = 0.22f)), + ) { + AnsiPalette( + colors = profile.ansiColors, + modifier = Modifier.padding(6.dp), + swatchHeight = 7.dp, + spacing = 3.dp, + ) + } +} + +@Composable +private fun TerminalProfilePreview(profile: TerminalColorProfile) { + Surface( + color = Color(profile.background), + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, Color(profile.foreground).copy(alpha = 0.22f)), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + "user@linux:~$ printf 'Hello'", + color = Color(profile.foreground), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + ) + AnsiPalette(colors = profile.ansiColors) + } + } +} + +@Composable +private fun AnsiPalette( + colors: List, + modifier: Modifier = Modifier, + selectedIndex: Int? = null, + onSelect: ((Int) -> Unit)? = null, + swatchHeight: Dp = if (onSelect == null) 14.dp else 32.dp, + spacing: Dp = 5.dp, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(spacing)) { + repeat(2) { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing), + ) { + repeat(8) { column -> + val index = row * 8 + column + Box( + modifier = + Modifier + .weight(1f) + .height(swatchHeight) + .background(Color(colors[index]), RoundedCornerShape(5.dp)) + .then( + if (onSelect == null) Modifier else Modifier.clickable { onSelect(index) }, + ), + contentAlignment = Alignment.Center, + ) { + if (selectedIndex == index) { + Text("•", color = contrastingColor(colors[index]), fontWeight = FontWeight.Bold) + } + } + } + } + } + } +} + +internal fun filterTerminalProfiles( + profiles: List, + query: String, +): List = + if (query.isBlank()) profiles else profiles.filter { it.name.contains(query.trim(), ignoreCase = true) } + +@Composable +private fun TerminalProfileEditor( + modifier: Modifier, + initial: TerminalColorProfile, + onBack: () -> Unit, + onSave: (TerminalColorProfile) -> Unit, +) { + var profile by remember(initial.id) { mutableStateOf(initial) } + var profileName by remember(initial.id) { mutableStateOf(initial.name) } + var selectedColor by remember { mutableIntStateOf(BACKGROUND_COLOR) } + val color = profile.colorAt(selectedColor) + var hexText by remember(selectedColor, color) { mutableStateOf(formatTerminalColor(color)) } + Column( + modifier = + modifier + .fillMaxSize() + .background(UdroidTerminal), + ) { + TerminalSettingsHeader(title = "Edit profile", onBack = onBack) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + OutlinedTextField( + value = profileName, + onValueChange = { profileName = it.take(40) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Profile name") }, + singleLine = true, + ) + } + item { TerminalProfilePreview(profile) } + item { + Text("Terminal colors", color = UdroidTerminalText, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ProfileColorButton("Background", profile.background, selectedColor == BACKGROUND_COLOR) { + selectedColor = BACKGROUND_COLOR + } + ProfileColorButton("Text", profile.foreground, selectedColor == FOREGROUND_COLOR) { + selectedColor = FOREGROUND_COLOR + } + ProfileColorButton("Cursor", profile.cursor, selectedColor == CURSOR_COLOR) { + selectedColor = CURSOR_COLOR + } + } + } + item { + Text("ANSI palette", color = UdroidTerminalText, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + AnsiPalette( + colors = profile.ansiColors, + selectedIndex = selectedColor.takeIf { it >= 0 }, + onSelect = { selectedColor = it }, + ) + } + item { + HorizontalDivider(color = UdroidTerminalLine) + Spacer(Modifier.height(12.dp)) + Text( + colorName(selectedColor), + color = UdroidTerminalText, + fontWeight = FontWeight.SemiBold, + ) + ColorSlider("Red", color.redChannel()) { + profile = profile.withColor(selectedColor, color.withChannel(red = it)) + } + ColorSlider("Green", color.greenChannel()) { + profile = profile.withColor(selectedColor, color.withChannel(green = it)) + } + ColorSlider("Blue", color.blueChannel()) { + profile = profile.withColor(selectedColor, color.withChannel(blue = it)) + } + OutlinedTextField( + value = hexText, + onValueChange = { value -> + hexText = value.take(7) + parseTerminalColor(hexText)?.let { profile = profile.withColor(selectedColor, it) } + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Hex value") }, + singleLine = true, + isError = parseTerminalColor(hexText) == null, + ) + } + item { + Button( + onClick = { onSave(profile.copy(name = profileName.trim())) }, + enabled = profileName.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Save profile") + } + } + } + } +} + +@Composable +private fun RowScope.ProfileColorButton( + label: String, + color: Int, + selected: Boolean, + onClick: () -> Unit, +) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp), + border = BorderStroke(2.dp, if (selected) UdroidTerminalGreen else UdroidTerminalLine), + ) { + Box(Modifier.size(14.dp).background(Color(color), RoundedCornerShape(3.dp))) + Spacer(Modifier.size(6.dp)) + Text(label, maxLines = 1, style = MaterialTheme.typography.labelMedium) + } +} + +@Composable +private fun ColorSlider( + label: String, + value: Int, + onValueChange: (Int) -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, modifier = Modifier.weight(0.28f), color = UdroidTerminalMuted) + Slider( + value = value.toFloat(), + onValueChange = { onValueChange(it.roundToInt()) }, + modifier = Modifier.weight(0.72f), + valueRange = 0f..255f, + ) + Text(value.toString(), modifier = Modifier.padding(start = 8.dp), color = UdroidTerminalText) + } +} + +private fun TerminalColorProfile.colorAt(index: Int): Int = + when (index) { + BACKGROUND_COLOR -> background + FOREGROUND_COLOR -> foreground + CURSOR_COLOR -> cursor + else -> ansiColors[index] + } + +private fun TerminalColorProfile.withColor( + index: Int, + color: Int, +): TerminalColorProfile = + when (index) { + BACKGROUND_COLOR -> copy(background = color) + FOREGROUND_COLOR -> copy(foreground = color) + CURSOR_COLOR -> copy(cursor = color) + else -> copy(ansiColors = ansiColors.toMutableList().also { it[index] = color }) + } + +private fun colorName(index: Int): String = + when (index) { + BACKGROUND_COLOR -> "Background" + FOREGROUND_COLOR -> "Text" + CURSOR_COLOR -> "Cursor" + in 0..7 -> "ANSI $index" + else -> "Bright ANSI $index" + } + +private fun Int.redChannel(): Int = (this shr 16) and 0xFF + +private fun Int.greenChannel(): Int = (this shr 8) and 0xFF + +private fun Int.blueChannel(): Int = this and 0xFF + +private fun Int.withChannel( + red: Int = redChannel(), + green: Int = greenChannel(), + blue: Int = blueChannel(), +): Int = 0xFF000000.toInt() or (red shl 16) or (green shl 8) or blue + +private fun contrastingColor(color: Int): Color { + val luminance = (color.redChannel() * 299 + color.greenChannel() * 587 + color.blueChannel() * 114) / 1000 + return if (luminance >= 128) Color.Black else Color.White +} + +private const val BACKGROUND_COLOR = -3 +private const val FOREGROUND_COLOR = -2 +private const val CURSOR_COLOR = -1 +private const val MAX_PALETTE_FILE_CHARS = 1_048_576 +private val INVALID_FILE_NAME = Regex("[^A-Za-z0-9._ -]") + +private fun Context.displayName(uri: Uri): String? = + contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + +private fun Context.readPaletteText(uri: Uri): String = + contentResolver.openInputStream(uri)?.bufferedReader()?.use { reader -> + buildString { + val buffer = CharArray(8192) + while (true) { + val count = reader.read(buffer) + if (count < 0) break + append(buffer, 0, count) + require(length <= MAX_PALETTE_FILE_CHARS) { "Palette file is larger than 1 MiB" } + } + } + } ?: error("Could not open file") diff --git a/app/src/test/java/org/randomcoder/udroid/filesystem/RootfsDocumentsProviderTest.kt b/app/src/test/java/org/randomcoder/udroid/filesystem/RootfsDocumentsProviderTest.kt new file mode 100644 index 0000000..84c2729 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/filesystem/RootfsDocumentsProviderTest.kt @@ -0,0 +1,45 @@ +package org.randomcoder.udroid.filesystem + +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.randomcoder.udroid.R + +class RootfsDocumentsProviderTest { + @Test + fun rootIconsFollowTheInstalledDistribution() { + assertEquals(R.drawable.distro_ubuntu, rootfsIconResource("udroid-jammy-raw")) + assertEquals(R.drawable.distro_debian, rootfsIconResource("debian-trixie")) + assertEquals(R.drawable.distro_unknown, rootfsIconResource("custom-linux")) + } + + @Test + fun savedInstallNameTakesPriorityOverTheInternalDirectoryName() { + assertEquals("Ubuntu 22.04 LTS", rootfsDisplayName("udroid-jammy-raw", "Ubuntu 22.04 LTS")) + assertEquals("Ubuntu Jammy", rootfsDisplayName("udroid-jammy-raw", null)) + } + + @Test + fun documentIdsRoundTripAndRejectTraversal() { + val id = RootfsDocumentId.decode(RootfsDocumentId.encode("ubuntu:noble", "usr/share/a b")) + assertEquals("ubuntu:noble", id.rootfs) + assertEquals("usr/share/a b", id.relative) + assertThrows(IllegalArgumentException::class.java) { + RootfsDocumentId.decode(RootfsDocumentId.encode("ubuntu", "../outside")) + } + } + + @Test + fun resolverKeepsRelativeAndAbsoluteSymlinksInsideGuestRoot() { + val root = Files.createTempDirectory("rootfs-provider") + Files.createDirectories(root.resolve("usr/bin")) + Files.write(root.resolve("usr/bin/tool"), "ok".toByteArray()) + Files.createSymbolicLink(root.resolve("bin"), root.fileSystem.getPath("usr/bin")) + Files.createSymbolicLink(root.resolve("sbin"), root.fileSystem.getPath("/usr/bin")) + + assertEquals(root.resolve("usr/bin/tool"), RootfsPathResolver.resolve(root, "bin/tool")) + assertEquals(root.resolve("usr/bin/tool"), RootfsPathResolver.resolve(root, "sbin/tool")) + assertThrows(IllegalArgumentException::class.java) { RootfsPathResolver.resolve(root, "../outside") } + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/install/RootfsPipelineTest.kt b/app/src/test/java/org/randomcoder/udroid/install/RootfsPipelineTest.kt index 13167e6..df9044f 100644 --- a/app/src/test/java/org/randomcoder/udroid/install/RootfsPipelineTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/install/RootfsPipelineTest.kt @@ -151,7 +151,76 @@ class RootfsPipelineTest { AndroidRootfsConfigurator().configure(rootfs) assertTrue(File(rootfs, "etc/hosts").readText().contains("localhost")) - assertTrue(File(rootfs, "etc/profile.d/udroid.sh").canExecute()) + val profile = File(rootfs, "etc/profile.d/udroid.sh") + val profileContents = profile.readText() + assertTrue(profile.canExecute()) + assertTrue(profileContents.contains("udroid.bash")) + val bashIntegration = File(rootfs, "etc/udroid/shell-integration/udroid.bash").readText() + assertTrue(bashIntegration.contains("133;A")) + assertTrue(bashIntegration.contains("133;B")) + assertTrue(bashIntegration.contains("133;C")) + assertTrue(bashIntegration.contains("133;D")) + assertTrue(bashIntegration.contains("kitty-shell-cwd://localhost")) + assertTrue(bashIntegration.contains("//%/%25")) + } + + @Test + fun `available shells receive native command lifecycle hooks`() { + val rootfs = Files.createTempDirectory("udroid-shell-configurator-test").toFile() + File(rootfs, "usr/bin").mkdirs() + File(rootfs, "usr/bin/zsh").createNewFile() + File(rootfs, "usr/bin/fish").createNewFile() + + val configurator = AndroidRootfsConfigurator() + configurator.configure(rootfs) + configurator.configureShellProfile(rootfs) + + val zshIntegration = File(rootfs, "etc/udroid/shell-integration/udroid.zsh").readText() + assertTrue(zshIntegration.contains("add-zsh-hook precmd")) + assertTrue(zshIntegration.contains("add-zsh-hook preexec")) + assertTrue(zshIntegration.contains("add-zsh-hook chpwd")) + assertTrue(zshIntegration.contains("kitty-shell-cwd://localhost")) + assertTrue(zshIntegration.contains("//%/%25")) + assertEquals( + 1, + File(rootfs, "etc/zsh/zshrc") + .readLines() + .count { it.contains("udroid.zsh") }, + ) + val fishIntegration = File(rootfs, "etc/fish/conf.d/udroid.fish").readText() + assertTrue(fishIntegration.contains("fish_prompt")) + assertTrue(fishIntegration.contains("fish_preexec")) + assertTrue(fishIntegration.contains("fish_postexec")) + assertTrue(fishIntegration.contains("--on-variable PWD")) + assertTrue(fishIntegration.contains("file://localhost")) + } + + @Test + fun `android compatibility configuration never follows guest symlinks`() { + val rootfs = Files.createTempDirectory("udroid-configurator-symlink").toFile() + val outside = Files.createTempDirectory("udroid-configurator-outside").toFile() + val profileDirectory = File(rootfs, "etc/profile.d") + try { + File(rootfs, "etc").mkdirs() + val outsideHosts = File(outside, "hosts").apply { writeText("keep me") } + Files.createSymbolicLink(File(rootfs, "etc/hosts").toPath(), outsideHosts.toPath()) + + AndroidRootfsConfigurator().configure(rootfs) + + assertEquals("keep me", outsideHosts.readText()) + assertFalse(Files.isSymbolicLink(File(rootfs, "etc/hosts").toPath())) + + profileDirectory.deleteRecursively() + Files.createSymbolicLink(profileDirectory.toPath(), outside.toPath()) + val failure = runCatching { AndroidRootfsConfigurator().configure(rootfs) }.exceptionOrNull() + + assertTrue(failure is IllegalStateException) + assertFalse(File(outside, "udroid.sh").exists()) + } finally { + if (Files.isSymbolicLink(profileDirectory.toPath())) Files.delete(profileDirectory.toPath()) + rootfs.deleteRecursively() + outside.deleteRecursively() + } } private fun fixture(): Fixture { diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotCompatibilityConfigurationTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotCompatibilityConfigurationTest.kt new file mode 100644 index 0000000..fde21d4 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotCompatibilityConfigurationTest.kt @@ -0,0 +1,29 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProotCompatibilityConfigurationTest { + @Test + fun `disabled compatibility adds no PRoot options`() { + val arguments = mutableListOf("proot") + + arguments.addProotCompatibilityOptions(ProotCompatibilityConfiguration()) + + assertEquals(listOf("proot"), arguments) + } + + @Test + fun `enabled compatibility adds supported PRoot options`() { + val arguments = mutableListOf("proot") + + arguments.addProotCompatibilityOptions( + ProotCompatibilityConfiguration( + sysvipcEnabled = true, + memfdFallbackEnabled = true, + ), + ) + + assertEquals(listOf("proot", "--sysvipc", "--ashmem-memfd"), arguments) + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotLaunchFlagsTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotLaunchFlagsTest.kt new file mode 100644 index 0000000..dc5731b --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotLaunchFlagsTest.kt @@ -0,0 +1,108 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files + +class ProotLaunchFlagsTest { + @Test + fun `missing file adds no launch arguments`() { + val directory = Files.createTempDirectory("udroid-launch-flags").toFile() + try { + assertEquals(emptyList(), ProotLaunchFlags.load(directory.resolve("missing.txt"))) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `each non-empty non-comment line is one literal argument`() { + val file = Files.createTempFile("udroid-launch-flags", ".txt").toFile() + try { + file.writeText( + """ + # Extra bindings for every Linux system + + -b + /storage/emulated/0/My Files:/mnt/my files + -v + """.trimIndent(), + ) + + assertEquals( + listOf("-b", "/storage/emulated/0/My Files:/mnt/my files", "-v"), + ProotLaunchFlags.load(file), + ) + } finally { + file.delete() + } + } + + @Test + fun `all proot arguments are passed through literally`() { + val file = Files.createTempFile("udroid-launch-flags", ".txt").toFile() + try { + val arguments = listOf("--", "-r", "--rootfs=/tmp/root", "-R/tmp/root", "--cwd=/tmp") + file.writeText(arguments.joinToString("\n")) + + assertEquals(arguments, ProotLaunchFlags.load(file)) + } finally { + file.delete() + } + } + + @Test + fun `global arguments reach terminal desktop and application launches`() { + val extra = listOf("-v", "2") + val terminal = + ProotTerminalLaunchBuilder.buildArguments( + linker = "linker64", + prootPath = "proot", + rootfsPath = "rootfs", + guestHome = "/root", + guestShell = "/bin/sh", + additionalProotArguments = extra, + ).toList() + val desktop = + ProotDesktopLaunchBuilder.buildArguments( + prootPath = "proot", + rootfsPath = "rootfs", + x11SocketDirectory = "/x11/.X11-unix", + guestHome = "/root", + environment = + DesktopEnvironment( + id = "xfce", + name = "Xfce", + command = listOf("startxfce4"), + desktopFilePath = "/usr/share/xsessions/xfce.desktop", + kind = DesktopEnvironmentKind.XFCE, + ), + configuration = + DesktopConfiguration( + environmentId = "xfce", + compositingEnabled = false, + touchScaleEnabled = false, + ), + hasDbusRunSession = false, + additionalProotArguments = extra, + ) + val application = + ProotApplicationLaunchBuilder.buildArguments( + prootPath = "proot", + rootfsPath = "rootfs", + x11SocketDirectory = "/x11/.X11-unix", + guestHome = "/root", + guestWorkingDirectory = "/root", + applicationArguments = listOf("/usr/bin/demo"), + additionalProotArguments = extra, + ) + + listOf(terminal, desktop, application).forEach { arguments -> + assertTrue(arguments.indexOf("-v") > arguments.indexOf("--rootfs=rootfs")) + assertTrue(arguments.indexOf("-v") > arguments.indexOfFirst { it.startsWith("--cwd=") }) + assertTrue(arguments.indexOf("-v") < arguments.indexOf("/usr/bin/env")) + assertEquals("2", arguments[arguments.indexOf("-v") + 1]) + } + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt index 42a721d..480ecc3 100644 --- a/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotMountProfilesTest.kt @@ -116,7 +116,11 @@ class ProotMountProfilesTest { Files.createSymbolicLink(directory.resolve("pci_devices").toPath(), victim.toPath()) val mounts = - ProotMountResolver.prepareCompatibilityMounts(directory) { path -> + ProotMountResolver.prepareCompatibilityMounts( + directory = directory, + nowEpochSeconds = 1_234L, + cpuCount = 2, + ) { path -> path == "/proc/version" } @@ -126,15 +130,49 @@ class ProotMountProfilesTest { }) assertEquals("preserve", victim.readText()) assertFalse(Files.isSymbolicLink(directory.resolve("pci_devices").toPath())) + assertEquals( + "cpu 2 0 2 2 0 0 0 0 0 0\n" + + "cpu0 1 0 1 1 0 0 0 0 0 0\n" + + "cpu1 1 0 1 1 0 0 0 0 0 0\n" + + "btime 1234\n", + mounts.single { it.guestTarget == "/proc/stat" }.let { + java.io.File(it.hostSource).readText() + }, + ) + assertEquals( + "Inter-| Receive | Transmit\n" + + " face |bytes packets errs drop fifo frame compressed multicast|" + + "bytes packets errs drop fifo colls carrier compressed\n", + mounts.single { it.guestTarget == "/proc/net" } + .let { java.io.File(it.hostSource, "dev").readText() }, + ) assertEquals( "4096\n", mounts.single { it.guestTarget.endsWith("max_user_watches") }.let { java.io.File(it.hostSource).readText() }, ) + assertEquals( + "nr_free_pages 0\npswpin 0\npswpout 0\n", + mounts.single { it.guestTarget == "/proc/vmstat" } + .let { java.io.File(it.hostSource).readText() }, + ) assertTrue(mounts.all { it.origin == "runtime:compatibility" }) } + @Test + fun `restricted system metric directories receive empty compatibility mounts`() { + val directory = Files.createTempDirectory("udroid-sysdata").toFile() + + val mounts = ProotMountResolver.prepareSystemCompatibilityMounts(directory) { false } + + assertEquals( + listOf("/sys/fs/selinux", "/sys/block", "/sys/class/power_supply"), + mounts.map(ResolvedProotMount::guestTarget), + ) + assertTrue(mounts.all { java.io.File(it.hostSource).isDirectory }) + } + @Test fun `parent custom mount shadows automatic child mounts`() { assertTrue( diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt index 3af917c..36716a8 100644 --- a/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt @@ -43,6 +43,81 @@ class ProotTerminalLaunchTest { ) } + @Test + fun `resolved guest working directory is passed to proot`() { + val arguments = + ProotTerminalLaunchBuilder.buildArguments( + linker = "linker64", + prootPath = "proot", + rootfsPath = "rootfs", + guestHome = "/root", + guestWorkingDirectory = "/root/project", + guestShell = "/bin/bash", + ) + + assertTrue("--cwd=/root/project" in arguments) + } + + @Test + fun `working directory resolves inside rootfs and bind mounts`() { + val rootfs = Files.createTempDirectory("udroid-cwd-rootfs").toFile() + val mounted = Files.createTempDirectory("udroid-cwd-mount").toFile() + try { + rootfs.resolve("root/project").mkdirs() + mounted.resolve("sources").mkdirs() + val mounts = listOf(ResolvedProotMount(mounted.path, "/mnt/shared", "test")) + + assertEquals( + "/root/project", + ProotTerminalLaunchBuilder.resolveGuestWorkingDirectory( + rootfs, + mounts, + "/root/project", + "/root", + ), + ) + assertEquals( + "/mnt/shared/sources", + ProotTerminalLaunchBuilder.resolveGuestWorkingDirectory( + rootfs, + mounts, + "/mnt/shared/sources", + "/root", + ), + ) + } finally { + rootfs.deleteRecursively() + mounted.deleteRecursively() + } + } + + @Test + fun `missing or unsafe working directory falls back home`() { + val rootfs = Files.createTempDirectory("udroid-cwd-fallback").toFile() + try { + assertEquals( + "/root", + ProotTerminalLaunchBuilder.resolveGuestWorkingDirectory( + rootfs, + emptyList(), + "/root/../etc", + "/root", + ), + ) + assertEquals( + "/root", + ProotTerminalLaunchBuilder.resolveGuestWorkingDirectory( + rootfs, + emptyList(), + "/missing", + "/root", + ), + ) + } finally { + rootfs.deleteRecursively() + } + } + @Test fun `all Android and guest bridge mounts are explicit`() { val arguments = diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/TerminalAppearanceTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalAppearanceTest.kt new file mode 100644 index 0000000..0cd1d8a --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalAppearanceTest.kt @@ -0,0 +1,58 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test + +class TerminalAppearanceTest { + @Test + fun parsesRgbHexWithOptionalHash() { + assertEquals(0xFF1122AA.toInt(), parseTerminalColor("#1122aa")) + assertEquals(0xFF43D292.toInt(), parseTerminalColor(" 43D292 ")) + } + + @Test + fun rejectsIncompleteOrNonRgbColors() { + assertNull(parseTerminalColor("#123")) + assertNull(parseTerminalColor("#12345678")) + assertNull(parseTerminalColor("#12ZZ56")) + } + + @Test + fun formatsColorsWithoutAlpha() { + assertEquals("#43D292", formatTerminalColor(0x7F43D292)) + } + + @Test + fun profileSerializationRoundTripsEveryTerminalColor() { + val profile = + TerminalProfiles.udroid.copy( + id = "custom-profile", + name = "My terminal | profile", + ) + + assertEquals(profile, decodeTerminalProfile(encodeTerminalProfile(profile))) + } + + @Test + fun malformedProfilesAreIgnored() { + assertNull(decodeTerminalProfile("not-a-profile")) + assertNull(decodeTerminalProfile(encodeTerminalProfile(TerminalProfiles.udroid).replace("#000000", "bad"))) + } + + @Test + fun profilesRequireAllSixteenAnsiColors() { + assertThrows(IllegalArgumentException::class.java) { + TerminalProfiles.udroid.copy(ansiColors = listOf(0xFF000000.toInt())) + } + } + + @Test + fun missingSelectionFallsBackToUdroid() { + val state = TerminalProfileState(selectedProfileId = "missing", userProfiles = emptyList()) + + assertSame(TerminalProfiles.udroid, state.selectedProfile) + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/TerminalProfileCodecTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalProfileCodecTest.kt new file mode 100644 index 0000000..f73c3e3 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalProfileCodecTest.kt @@ -0,0 +1,84 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalProfileCodecTest { + @Test + fun termuxExportCanBeImportedWithoutLosingColors() { + val source = TerminalProfiles.builtIns[1].copy(id = "source") + + val imported = importTerminalProfile("Breeze.properties", exportTermuxProfile(source)).getOrThrow() + + assertEquals("Breeze", imported.name) + assertEquals(source.background, imported.background) + assertEquals(source.foreground, imported.foreground) + assertEquals(source.cursor, imported.cursor) + assertEquals(source.ansiColors, imported.ansiColors) + } + + @Test + fun importsGhosttyPalette() { + val source = TerminalProfiles.udroid + val theme = + buildString { + appendLine("background = ${formatTerminalColor(source.background)}") + appendLine("foreground = ${formatTerminalColor(source.foreground)}") + appendLine("cursor-color = ${formatTerminalColor(source.cursor)}") + source.ansiColors.forEachIndexed { index, color -> + appendLine("palette = $index=${formatTerminalColor(color)}") + } + } + + val imported = importTerminalProfile("Ghostty theme", theme).getOrThrow() + + assertEquals(source.cursor, imported.cursor) + assertEquals(source.ansiColors, imported.ansiColors) + } + + @Test + fun importsItermColors() { + val source = TerminalProfiles.udroid + val colors = + buildMap { + put("Background Color", source.background) + put("Foreground Color", source.foreground) + put("Cursor Color", source.cursor) + source.ansiColors.forEachIndexed { index, color -> put("Ansi $index Color", color) } + } + val xml = + buildString { + append("") + colors.forEach { (key, color) -> + append("$key") + appendComponent("Red Component", (color shr 16) and 0xFF) + appendComponent("Green Component", (color shr 8) and 0xFF) + appendComponent("Blue Component", color and 0xFF) + append("") + } + append("") + } + + val imported = importTerminalProfile("uDroid.itermcolors", xml).getOrThrow() + + assertEquals(source.background, imported.background) + assertEquals(source.foreground, imported.foreground) + assertEquals(source.cursor, imported.cursor) + assertEquals(source.ansiColors, imported.ansiColors) + } + + @Test + fun rejectsIncompletePalettes() { + val result = importTerminalProfile("broken.properties", "background=#000000\nforeground=#FFFFFF") + + assertTrue(result.isFailure) + } + + private fun StringBuilder.appendComponent( + name: String, + value: Int, + ) { + append("$name${value / 255.0}") + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/TerminalTabRegistryTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalTabRegistryTest.kt index 0ba2812..2a1efcc 100644 --- a/app/src/test/java/org/randomcoder/udroid/runtime/TerminalTabRegistryTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalTabRegistryTest.kt @@ -51,6 +51,43 @@ class TerminalTabRegistryTest { assertEquals("Terminal two", registry.get("two")?.title) } + @Test + fun `custom title overrides shell updates until automatic titles are restored`() { + val registry = TerminalTabRegistry() + registry.add(tab("one")) + + registry.updateShellTitle("one", "root@localhost: ~") + assertEquals("root@localhost: ~", registry.get("one")?.title) + + registry.rename("one", "Server") + registry.updateShellTitle("one", "htop") + assertEquals("Server", registry.get("one")?.title) + + registry.followShellTitle("one") + assertEquals("htop", registry.get("one")?.title) + } + + @Test + fun `empty shell title falls back to default title`() { + val registry = TerminalTabRegistry() + registry.add(tab("one")) + registry.updateShellTitle("one", "htop") + + registry.updateShellTitle("one", null) + + assertEquals("Terminal one", registry.get("one")?.title) + } + + @Test + fun `terminal titles drop controls and remain bounded`() { + val title = "\u001B]0;\u202Eroot@localhost\n" + "x".repeat(50) + + assertEquals( + " ]0;root@localhost".trim() + "x".repeat(23), + normalizeTerminalTitle(title), + ) + } + @Test fun `removing last tab clears active id`() { val registry = TerminalTabRegistry() @@ -73,6 +110,50 @@ class TerminalTabRegistryTest { assertEquals("debian-bookworm", registry.get("debian")?.rootfsName) } + @Test + fun `closing state prevents duplicate close and supports retry after failure`() { + val registry = TerminalTabRegistry() + registry.add(tab("one")) + + assertEquals(TerminalTabCloseState.CLOSING, registry.beginClose("one")?.closeState) + assertNull(registry.beginClose("one")) + assertEquals( + TerminalTabCloseState.FORCE_CLOSING, + registry.markForceClosing("one")?.closeState, + ) + assertEquals( + TerminalTabCloseState.FAILED, + registry.markCloseFailed("one", "Could not close")?.closeState, + ) + assertEquals(TerminalTabCloseState.CLOSING, registry.beginClose("one")?.closeState) + assertNull(registry.get("one")?.closeError) + } + + @Test + fun `command state is isolated per tab and ignores duplicate markers`() { + val registry = TerminalTabRegistry() + registry.add(tab("one")) + registry.add(tab("two")) + + assertEquals("one", registry.updateCommandRunning("one", true)?.id) + assertNull(registry.updateCommandRunning("one", true)) + assertEquals(true, registry.get("one")?.commandRunning) + assertEquals(false, registry.get("two")?.commandRunning) + assertEquals(false, registry.updateCommandRunning("one", false)?.commandRunning) + } + + @Test + fun `working directory is isolated per tab and ignores duplicate reports`() { + val registry = TerminalTabRegistry() + registry.add(tab("one")) + registry.add(tab("two")) + + assertEquals("one", registry.updateWorkingDirectory("one", "/root/project")?.id) + assertNull(registry.updateWorkingDirectory("one", "/root/project")) + assertEquals("/root/project", registry.get("one")?.workingDirectory) + assertNull(registry.get("two")?.workingDirectory) + } + @Test fun `rootfs names become readable distro titles`() { assertEquals("Ubuntu Jammy", terminalDistroTitle("udroid-jammy-raw")) @@ -85,7 +166,7 @@ class TerminalTabRegistryTest { ) = TerminalTab( id = id, - title = "Terminal $id", + defaultTitle = "Terminal $id", rootfsName = rootfsName, value = Any(), ) diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/TerminalTypographyTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalTypographyTest.kt new file mode 100644 index 0000000..da373b4 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalTypographyTest.kt @@ -0,0 +1,95 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.StandardCharsets +import java.nio.file.Files + +class TerminalTypographyTest { + @Test + fun exposesBundledFontsAsBuiltIns() { + assertEquals( + listOf("System monospace", "JetBrains Mono", "Fira Code", "Source Code Pro"), + TerminalFonts.builtIns.map(TerminalFontProfile::name), + ) + } + + @Test + fun normalizesBoundsAndMissingFontSelection() { + val state = + TerminalTypographyState( + selectedFontId = "missing", + textSizeSp = 100f, + lineSpacing = 0.1f, + cursorBlinkRateMs = 50, + ).normalized() + + assertEquals(TerminalFonts.system.id, state.selectedFontId) + assertEquals(TERMINAL_TEXT_SIZE_MAX_SP, state.textSizeSp) + assertEquals( + TERMINAL_TEXT_SIZE_MIN_SP, + TerminalTypographyState(textSizeSp = 0f).normalized().textSizeSp, + ) + assertEquals(TERMINAL_LINE_SPACING_MIN, state.lineSpacing) + assertEquals(TERMINAL_CURSOR_BLINK_RATE_MIN_MS, state.cursorBlinkRateMs) + } + + @Test + fun fontMetadataRoundTrips() { + val font = TerminalFontProfile("font-id", "JetBrains Mono", "123e4567-e89b-12d3-a456-426614174000.ttf") + + assertEquals(font, decodeTerminalFont(encodeTerminalFont(font))) + } + + @Test + fun malformedFontMetadataIsIgnored() { + assertNull(decodeTerminalFont("not-a-font")) + assertNull(decodeTerminalFont("font|Rm9udA|../font.ttf")) + } + + @Test + fun readsFullNameFromOpenTypeNameTable() { + val family = "Example Mono".toByteArray(StandardCharsets.UTF_16BE) + val fullName = "Example Mono Regular".toByteArray(StandardCharsets.UTF_16BE) + val nameTableLength = 6 + 24 + family.size + fullName.size + val buffer = ByteBuffer.allocate(28 + nameTableLength).order(ByteOrder.BIG_ENDIAN) + buffer.putInt(0x00010000) + buffer.putShort(1) + repeat(3) { buffer.putShort(0) } + buffer.put("name".toByteArray(StandardCharsets.US_ASCII)) + buffer.putInt(0) + buffer.putInt(28) + buffer.putInt(nameTableLength) + buffer.putShort(0) + buffer.putShort(2) + buffer.putShort(30) + buffer.putNameRecord(nameId = 1, length = family.size, offset = 0) + buffer.putNameRecord(nameId = 4, length = fullName.size, offset = family.size) + buffer.put(family) + buffer.put(fullName) + val file = Files.createTempFile("font-name", ".ttf").toFile() + + try { + file.writeBytes(buffer.array()) + assertEquals("Example Mono Regular", readOpenTypeFontName(file)) + } finally { + file.delete() + } + } + + private fun ByteBuffer.putNameRecord( + nameId: Int, + length: Int, + offset: Int, + ) { + putShort(3) + putShort(1) + putShort(0x0409.toShort()) + putShort(nameId.toShort()) + putShort(length.toShort()) + putShort(offset.toShort()) + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/ui/TerminalLinkTest.kt b/app/src/test/java/org/randomcoder/udroid/ui/TerminalLinkTest.kt new file mode 100644 index 0000000..a8462af --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/ui/TerminalLinkTest.kt @@ -0,0 +1,17 @@ +package org.randomcoder.udroid.ui + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalLinkTest { + @Test + fun onlyWebAndMailLinksCanLeaveTheTerminal() { + assertTrue(terminalLinkCanOpen("https")) + assertTrue(terminalLinkCanOpen("HTTP")) + assertTrue(terminalLinkCanOpen("mailto")) + assertFalse(terminalLinkCanOpen("intent")) + assertFalse(terminalLinkCanOpen("javascript")) + assertFalse(terminalLinkCanOpen(null)) + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/ui/TerminalSettingsPageTest.kt b/app/src/test/java/org/randomcoder/udroid/ui/TerminalSettingsPageTest.kt new file mode 100644 index 0000000..d8a6057 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/ui/TerminalSettingsPageTest.kt @@ -0,0 +1,23 @@ +package org.randomcoder.udroid.ui + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.randomcoder.udroid.runtime.TerminalProfiles + +class TerminalSettingsPageTest { + @Test + fun profileSearchMatchesNamesCaseInsensitively() { + assertEquals( + listOf("Catppuccin Mocha"), + filterTerminalProfiles(TerminalProfiles.builtIns, " CATPPUCCIN ").map { it.name }, + ) + } + + @Test + fun blankProfileSearchKeepsEveryProfile() { + assertEquals( + TerminalProfiles.builtIns, + filterTerminalProfiles(TerminalProfiles.builtIns, " "), + ) + } +} diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d991677..de499dc 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -45,6 +45,109 @@ The terminal stays alive when you move between uDroid pages. Use **Stop** on the system page when you want to end Linux or change settings that require it to be stopped. +### Terminal colors + +Select the palette button in **Terminal** to open **Terminal appearance**, then +choose **Colors**. You can choose a built-in profile, create one, or customize +a built-in profile as an editable copy. Changes apply to the running terminal +and are kept for new terminal sessions. Built-in choices include uDroid, +Breeze, Solarized, +Dracula, Catppuccin Mocha, Gruvbox Dark, Nord, Tokyo Night, Rosé Pine, and +Ubuntu. + +**Import** accepts these common palette formats: + +- Termux `colors.properties` files with `background`, `foreground`, optional + `cursor`, and `color0` through `color15`. +- Ghostty theme files with `background`, `foreground`, optional `cursor-color`, + and `palette = 0` through `palette = 15` entries. +- iTerm2 `.itermcolors` files. + +The foreground, background, and all 16 ANSI colors must be present. If the +cursor color is omitted, uDroid uses the foreground color. Other settings in a +theme file—such as fonts, key bindings, commands, or window options—are ignored +and never executed. Imported files must be no larger than 1 MiB. The imported +palette becomes an editable custom profile and is selected immediately. + +**Export selected** always writes a Termux-compatible `colors.properties` +file. Export a built-in profile first if you want a known-good template to edit +or share. + +You can find compatible palettes here: + +- [Termux:Styling colors](https://github.com/termux/termux-styling/tree/master/app/src/main/assets/colors) + are ready-to-import `colors.properties` files. +- [iTerm2 Color Schemes](https://github.com/mbadolato/iTerm2-Color-Schemes/releases) + publishes downloadable Termux, Ghostty, and iTerm2 theme archives. +- [Ghostty themes](https://ghostty.org/docs/features/theme) explains Ghostty's + theme format and built-in theme collection. + +Base16 and Base24 scheme files are not imported directly. Use a Termux, +Ghostty, or iTerm2 version of the scheme instead. Programs may temporarily +change colors in their own terminal session; the saved profile remains the +base appearance for the next session. + +### Terminal font and text + +Open **Terminal appearance**, then **Font & text**, to change the terminal font, +text size, line height, cursor shape, and cursor blinking. The preview shows the +selected settings before you return to the terminal. Pinch inside the terminal +to change the same saved text-size setting. + +The app includes JetBrains Mono, Fira Code, and Source Code Pro. You can also +import a `.ttf` or `.otf` font from **Fonts**. + +**Import font** accepts one or more TTF and OTF files up to 16 MiB each. uDroid +uses the name stored inside each font, validates it, and copies it into the +app's private storage. Moving or deleting the original download does not remove +it from the terminal. Use a monospaced font to keep columns and terminal +interfaces aligned. Imported fonts can be removed from the font list; **Reset +font & text** selects Android's system monospace and restores the other defaults +without deleting imported fonts. + +[Google Fonts](https://fonts.google.com/?categoryFilters=Appearance:%2FMonospace%2FMonospace) +provides downloadable monospaced fonts. Check a font's license before sharing +or redistributing its file. + +### Terminal OSC compatibility + +Terminal programs use Operating System Command (OSC) sequences to communicate +with the terminal outside the normal text grid. uDroid currently supports: + +| Sequence | Support | uDroid behavior | +| --- | --- | --- | +| OSC 0, 1, 2 | Supported | Sets the shell title. A manually renamed tab keeps its name until **Follow shell title** is selected. | +| OSC 4 | Supported | Changes indexed terminal colors for the current session. | +| OSC 10, 11, 12 | Supported | Changes the current foreground, background, and cursor colors. | +| OSC 52 | Write only | Copies Base64-encoded text from a terminal program to the Android clipboard. | +| OSC 104, 110, 111, 112 | Supported | Resets indexed colors, foreground, background, or cursor color. | +| OSC 133 A/B/C/D | Bash, Zsh, and Fish | Tracks whether the foreground command is running so uDroid can warn before closing its tab. | +| OSC 7 | Bash, Zsh, and Fish | Tracks the guest working directory so a new tab for the same Linux system opens in the current directory. | +| OSC 8 | Supported | Underlines hyperlinks and offers **Open** or **Copy** when selected. | +| OSC 9, 777 | Not supported | Terminal programs cannot create Android notifications. | +| OSC 9;4 | Not supported | Terminal progress is not shown in the Android UI. | +| OSC 1337 images | Not supported | Inline image rendering is not implemented by the embedded Termux terminal. | + +Supported OSC sequences may end with either BEL or ST. OSC 133 command +tracking is automatically configured for interactive Bash, Zsh, and Fish +shells. OSC 7 working-directory tracking is configured for the same shells and +accepts local `file://` and `kitty-shell-cwd://` URIs. Remote hosts, unsafe +paths, and directories that no longer exist fall back to the Linux home +directory. A newly installed Zsh or Fish is detected when the next uDroid +terminal starts. Tracking cannot detect background jobs after the prompt +returns, and a prompt framework that replaces the shell's registered hooks may +disable it. +The exit status carried by OSC 133 D is accepted but is not shown or saved. + +OSC 52 lets command output replace the Android clipboard, so run untrusted +terminal programs with the same care as programs that can print arbitrary +terminal control sequences. OSC 8 opens only `http`, `https`, and `mailto` +links after confirmation; other link types can still be copied. See the +[xterm control-sequence reference](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) +for title, color, and clipboard sequences, and +[VS Code shell integration](https://code.visualstudio.com/docs/terminal/shell-integration) +for the OSC 133 A/B/C/D protocol used by the Bash integration. + ### Desktop uDroid can start X11 desktop sessions installed inside Linux. @@ -145,6 +248,10 @@ Open **File access** to choose which Android and system paths Linux can see. Internal shared storage can be exposed at `/mnt/shared`. Custom mounts are for other Android paths the app can read. +Android's Files app also lists each installed Linux system as a read-only +location. Use it to browse or open Linux files without sharing the whole +filesystem with Android apps; file changes still belong in Linux. + Stop Linux before changing mounts. Session mounts used by the display, audio, or graphics features are added automatically only when those features need them. @@ -154,6 +261,23 @@ terminal, app, and desktop launches. Restart Linux when an existing terminal needs the new values. **Reset** restores uDroid-managed values without removing your custom variables. +Advanced users can add global PRoot arguments in +`/storage/emulated/0/udroid-launch-flags.txt`. Put one complete argument on each +line; blank lines and lines beginning with `#` are ignored. Options with a value +use two lines, for example: + +```text +-b +/storage/emulated/0/Projects:/mnt/projects +``` + +The file is read again for every terminal, desktop, and Linux app launch. It +requires Android storage access. Arguments are passed to PRoot without policy +restrictions after uDroid's own PRoot options, so duplicate options can override +uDroid defaults and invalid arguments can break the complete launch command. +Keep the file under your control because another app with all-files access +could edit it. + ## Reset or delete a system Stop its terminal and desktop first. diff --git a/third_party/termux/README.udroid.md b/third_party/termux/README.udroid.md index ed05bdc..7b4144a 100644 --- a/third_party/termux/README.udroid.md +++ b/third_party/termux/README.udroid.md @@ -16,10 +16,12 @@ The upstream license record is retained next to this file. required by current Android build tools. - `terminal-emulator/src/main/jni/Android.mk` links `libtermux.so` with `-Wl,-z,max-page-size=16384`. +- `TerminalRenderer` accepts a line-spacing multiplier, and + `TerminalView.setTextAppearance()` applies font, size, and line height with a + single PTY resize. -No Java terminal behavior was changed. uDroid owns the -`TerminalSessionClient`, `TerminalViewClient`, service lifecycle, PRoot launch -vector, and Compose integration outside this directory. +uDroid owns the `TerminalSessionClient`, `TerminalViewClient`, service +lifecycle, PRoot launch vector, and Compose integration outside this directory. ## Reproducibility check diff --git a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java index 21d6518..54fc8c9 100644 --- a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java +++ b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java @@ -290,21 +290,32 @@ public void resize(int newColumns, int newRows, int newTotalRows, int[] cursor, lastNonSpaceIndex = oldLine.getSpaceUsed(); if (cursorAtThisRow) justToCursor = true; } else { - for (int i = 0; i < oldLine.getSpaceUsed(); i++) - // NEWLY INTRODUCED BUG! Should not index oldLine.mStyle with char indices - if (oldLine.mText[i] != ' '/* || oldLine.mStyle[i] != currentStyle */) - lastNonSpaceIndex = i + 1; + int column = 0; + for (int i = 0; i < oldLine.getSpaceUsed();) { + int codePoint = Character.codePointAt(oldLine.mText, i, oldLine.getSpaceUsed()); + i += Character.charCount(codePoint); + // Hyperlinks occupy cells even when their labels are spaces. Track + // terminal columns separately from UTF-16 indices and combining marks. + if (codePoint != ' ' || oldLine.getHyperlink(column) != 0) + lastNonSpaceIndex = i; + column += Math.max(0, WcWidth.width(codePoint)); + } } int currentOldCol = 0; long styleAtCol = 0; + int linkAtCol = 0; for (int i = 0; i < lastNonSpaceIndex; i++) { // Note that looping over java character, not cells. char c = oldLine.mText[i]; int codePoint = (Character.isHighSurrogate(c)) ? Character.toCodePoint(c, oldLine.mText[++i]) : c; int displayWidth = WcWidth.width(codePoint); // Use the last style if this is a zero-width character: - if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol); + if (displayWidth > 0) { + styleAtCol = oldLine.getStyle(currentOldCol); + // An OSC 8 hyperlink reflows with its cell, as the style does. + linkAtCol = oldLine.getHyperlink(currentOldCol); + } // Line wrap as necessary: if (currentOutputExternalColumn + displayWidth > mColumns) { @@ -320,7 +331,7 @@ public void resize(int newColumns, int newRows, int newTotalRows, int[] cursor, int offsetDueToCombiningChar = ((displayWidth <= 0 && currentOutputExternalColumn > 0) ? 1 : 0); int outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar; - setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol); + setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol, linkAtCol); if (displayWidth > 0) { if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) { @@ -449,10 +460,15 @@ public TerminalRow allocateFullLineIfNecessary(int row) { } public void setChar(int column, int row, int codePoint, long style) { + setChar(column, row, codePoint, style, 0); + } + + /** as {@link #setChar(int, int, int, long)}, printed under the OSC 8 hyperlink {@code link} (0 for none). */ + public void setChar(int column, int row, int codePoint, long style, int link) { if (row < 0 || row >= mScreenRows || column < 0 || column >= mColumns) throw new IllegalArgumentException("TerminalBuffer.setChar(): row=" + row + ", column=" + column + ", mScreenRows=" + mScreenRows + ", mColumns=" + mColumns); row = externalToInternalRow(row); - allocateFullLineIfNecessary(row).setChar(column, codePoint, style); + allocateFullLineIfNecessary(row).setChar(column, codePoint, style, link); } public long getStyleAt(int externalRow, int column) { diff --git a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java index 8d7eaa8..4650976 100644 --- a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -2,8 +2,11 @@ import android.util.Base64; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.HashMap; import java.util.Locale; import java.util.Objects; import java.util.Stack; @@ -188,6 +191,29 @@ public final class TerminalEmulator { /** Holds OSC and device control arguments, which can be strings. */ private final StringBuilder mOSCOrDeviceControlArgs = new StringBuilder(); + /** Whether OSC 133 has marked a foreground command as running. */ + private boolean mCommandRunning; + + // Adapted from termux/termux-app#5390 by Matt Schwartz. + static final int MAX_HYPERLINKS = 1024; + private final String[] mHyperlinkUris = new String[MAX_HYPERLINKS + 1]; + private final String[] mHyperlinkKeys = new String[MAX_HYPERLINKS + 1]; + private final HashMap mHyperlinkIds = new HashMap<>(); + private int mHyperlink; + private int mNextHyperlink = 1; + private int mHyperlinkOpensSinceSweep; + + /** Resolve the OSC 8 URI stored on a terminal cell, or null. */ + public String getHyperlinkUriAt(int row, int column) { + if (row < -mScreen.getActiveTranscriptRows() || row >= mRows || column < 0 || column >= mColumns) return null; + TerminalRow line = mScreen.mLines[mScreen.externalToInternalRow(row)]; + return line == null ? null : getHyperlinkUri(line.getHyperlink(column)); + } + + public String getHyperlinkUri(int id) { + return id > 0 && id <= MAX_HYPERLINKS ? mHyperlinkUris[id] : null; + } + /** * True if the current escape sequence should continue, false if the current escape sequence should be terminated. * Used when parsing a single character. @@ -2069,6 +2095,12 @@ private void doOscSetTextParameters(String bellOrStringTerminator) { if (endOfInput) break; } break; + case 7: // Report the shell's current working directory. + handleWorkingDirectory(textParameter); + break; + case 8: // Hyperlink: 8;params;URI opens, 8;; closes. + setHyperlink(textParameter); + break; case 10: // Set foreground color. case 11: // Set background color. case 12: // Set cursor color. @@ -2110,6 +2142,9 @@ private void doOscSetTextParameters(String bellOrStringTerminator) { Logger.logError(mClient, LOG_TAG, "OSC Manipulate selection, invalid string '" + textParameter + ""); } break; + case 133: // FinalTerm semantic prompt markers. + handleSemanticPrompt(textParameter); + break; case 104: // "104;$c" → Reset Color Number $c. It is reset to the color specified by the corresponding X // resource. Any number of c parameters may be given. These parameters correspond to the ANSI colors 0-7, @@ -2152,6 +2187,64 @@ private void doOscSetTextParameters(String bellOrStringTerminator) { finishSequence(); } + private void setHyperlink(String parameters) { + int split = parameters.indexOf(';'); + String uri = split < 0 ? "" : parameters.substring(split + 1); + if (uri.isEmpty()) { + mHyperlink = 0; + return; + } + String explicitId = null; + for (String parameter : parameters.substring(0, split).split(":", -1)) { + if (parameter.startsWith("id=") && parameter.length() > 3) explicitId = parameter.substring(3); + } + String key = explicitId == null ? null : explicitId + '\u0000' + uri; + Integer existing = key == null ? null : mHyperlinkIds.get(key); + mHyperlink = existing != null ? existing : allocateHyperlink(key, uri); + } + + private int allocateHyperlink(String key, String uri) { + mHyperlinkOpensSinceSweep++; + int id = freeHyperlink(); + if (id == 0 && mHyperlinkOpensSinceSweep >= MAX_HYPERLINKS / 4) { + mHyperlinkOpensSinceSweep = 0; + sweepHyperlinks(); + id = freeHyperlink(); + } + if (id == 0) return 0; + mHyperlinkUris[id] = uri; + mHyperlinkKeys[id] = key; + if (key != null) mHyperlinkIds.put(key, id); + mNextHyperlink = id % MAX_HYPERLINKS + 1; + return id; + } + + private int freeHyperlink() { + for (int step = 0; step < MAX_HYPERLINKS; step++) { + int id = (mNextHyperlink - 1 + step) % MAX_HYPERLINKS + 1; + if (mHyperlinkUris[id] == null) return id; + } + return 0; + } + + private void sweepHyperlinks() { + boolean[] live = new boolean[MAX_HYPERLINKS + 1]; + live[mHyperlink] = true; + for (TerminalBuffer buffer : new TerminalBuffer[]{mMainBuffer, mAltBuffer}) { + for (TerminalRow row : buffer.mLines) { + if (row != null && row.mLinks != null) { + for (int link : row.mLinks) live[link] = true; + } + } + } + for (int id = 1; id <= MAX_HYPERLINKS; id++) { + if (live[id] || mHyperlinkUris[id] == null) continue; + if (mHyperlinkKeys[id] != null) mHyperlinkIds.remove(mHyperlinkKeys[id]); + mHyperlinkUris[id] = null; + mHyperlinkKeys[id] = null; + } + } + private void blockClear(int sx, int sy, int w) { blockClear(sx, sy, w, 1); } @@ -2288,6 +2381,60 @@ private void collectOSCArgs(int b) { } } + private void handleSemanticPrompt(String parameter) { + if (parameter.isEmpty() || (parameter.length() > 1 && parameter.charAt(1) != ';')) return; + switch (parameter.charAt(0)) { + case 'C': + setCommandRunning(true); + break; + case 'A': + case 'B': + case 'D': + setCommandRunning(false); + break; + } + } + + private void handleWorkingDirectory(String uri) { + final String filePrefix = "file://"; + final String kittyPrefix = "kitty-shell-cwd://"; + boolean fileUri = uri.startsWith(filePrefix); + String remainder; + if (fileUri) { + remainder = uri.substring(filePrefix.length()); + } else if (uri.startsWith(kittyPrefix)) { + remainder = uri.substring(kittyPrefix.length()); + } else { + return; + } + + int pathStart = remainder.indexOf('/'); + if (pathStart < 0) return; + String authority = remainder.substring(0, pathStart); + if (!authority.isEmpty() && !"localhost".equalsIgnoreCase(authority)) return; + + String encodedPath = remainder.substring(pathStart); + if (fileUri && (encodedPath.indexOf('?') >= 0 || encodedPath.indexOf('#') >= 0)) return; + try { + String path = URLDecoder.decode(encodedPath.replace("+", "%2B"), StandardCharsets.UTF_8.name()); + if (path.length() > 4096 || path.charAt(0) != '/') return; + for (int i = 0; i < path.length(); i++) { + if (Character.isISOControl(path.charAt(i))) return; + } + for (String segment : path.split("/", -1)) { + if (".".equals(segment) || "..".equals(segment)) return; + } + mSession.onWorkingDirectoryChanged(path); + } catch (IllegalArgumentException | UnsupportedEncodingException ignored) { + } + } + + private void setCommandRunning(boolean running) { + if (mCommandRunning == running) return; + mCommandRunning = running; + mSession.onCommandStateChanged(running); + } + private void unimplementedSequence(int b) { logError("Unimplemented sequence char '" + (char) b + "' (U+" + String.format("%04x", b) + ")"); finishSequence(); @@ -2484,7 +2631,7 @@ private void emitCodePoint(int codePoint) { // so was mCursorCol changed after the offsetDueToCombiningChar conditional by another thread? // TODO: Check if there are thread synchronization issues with mCursorCol and mCursorRow, possibly causing others bugs too. if (column < 0) column = 0; - mScreen.setChar(column, mCursorRow, codePoint, getStyle()); + mScreen.setChar(column, mCursorRow, codePoint, getStyle(), mHyperlink); if (autoWrap && displayWidth > 0) mAboutToAutoWrap = (mCursorCol == mRightMargin - displayWidth); @@ -2524,6 +2671,8 @@ public void clearScrollCounter() { /** Reset terminal state so user can interact with it regardless of present state. */ public void reset() { + mHyperlink = 0; + setCommandRunning(false); setCursorStyle(); mArgIndex = 0; mContinueSequence = false; diff --git a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java index 305082a..4a4b5aa 100644 --- a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java +++ b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java @@ -18,6 +18,12 @@ public final void write(String data) { /** Notify the terminal client that the terminal title has changed. */ public abstract void titleChanged(String oldTitle, String newTitle); + /** Notify the terminal client that OSC 133 marked a command as running or finished. */ + public abstract void onCommandStateChanged(boolean running); + + /** Notify the terminal client that OSC 7 reported the shell's current directory. */ + public abstract void onWorkingDirectoryChanged(String path); + /** Notify the terminal client that text should be copied to clipboard. */ public abstract void onCopyTextToClipboard(String text); diff --git a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java index d68dc32..46d9fe0 100644 --- a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java +++ b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java @@ -47,6 +47,8 @@ public final class TerminalRow { boolean mLineWrap; /** The style bits of each cell in the row. See {@link TextStyle}. */ final long[] mStyle; + /** OSC 8 hyperlink id for each cell, or null while this row has no links. */ + int[] mLinks; /** If this row might contain chars with width != 1, used for deactivating fast path */ boolean mHasNonOneWidthOrSurrogateChars; @@ -65,6 +67,7 @@ public void copyInterval(TerminalRow line, int sourceX1, int sourceX2, int desti final int x2 = line.findStartOfColumn(sourceX2); boolean startingFromSecondHalfOfWideChar = (sourceX1 > 0 && line.wideDisplayCharacterStartingAt(sourceX1 - 1)); final char[] sourceChars = (this == line) ? Arrays.copyOf(line.mText, line.mText.length) : line.mText; + final int[] sourceLinks = (this == line && line.mLinks != null) ? line.mLinks.clone() : line.mLinks; int latestNonCombiningWidth = 0; for (int i = x1; i < x2; i++) { char sourceChar = sourceChars[i]; @@ -80,7 +83,7 @@ public void copyInterval(TerminalRow line, int sourceX1, int sourceX2, int desti sourceX1 += latestNonCombiningWidth; latestNonCombiningWidth = w; } - setChar(destinationX, codePoint, line.getStyle(sourceX1)); + setChar(destinationX, codePoint, line.getStyle(sourceX1), sourceLinks == null ? 0 : sourceLinks[sourceX1]); } } @@ -146,16 +149,22 @@ public void clear(long style) { Arrays.fill(mStyle, style); mSpaceUsed = (short) mColumns; mHasNonOneWidthOrSurrogateChars = false; + mLinks = null; } // https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26 public void setChar(int columnToSet, int codePoint, long style) { + setChar(columnToSet, codePoint, style, 0); + } + + /** Write a cell carrying an OSC 8 hyperlink id (0 for none). */ + public void setChar(int columnToSet, int codePoint, long style, int link) { if (columnToSet < 0 || columnToSet >= mStyle.length) throw new IllegalArgumentException("TerminalRow.setChar(): columnToSet=" + columnToSet + ", codePoint=" + codePoint + ", style=" + style); - mStyle[columnToSet] = style; - final int newCodePointDisplayWidth = WcWidth.width(codePoint); + if (newCodePointDisplayWidth > 0) setHyperlink(columnToSet, link); + mStyle[columnToSet] = style; // Fast path when we don't have any chars with width != 1 if (!mHasNonOneWidthOrSurrogateChars) { @@ -174,6 +183,7 @@ public void setChar(int columnToSet, int codePoint, long style) { if (newIsCombining) { // When standing at second half of wide character and inserting combining: if (wasExtraColForWideChar) columnToSet--; + link = getHyperlink(columnToSet); } else { // Check if we are overwriting the second half of a wide character starting at the previous column: if (wasExtraColForWideChar) setChar(columnToSet - 1, ' ', style); @@ -268,11 +278,34 @@ public void setChar(int columnToSet, int codePoint, long style) { mSpaceUsed -= nextLen; } } + + setHyperlink(columnToSet, link); + if (columnToSet + 1 < mColumns) { + if (newCodePointDisplayWidth == 2 || (newIsCombining && oldCodePointDisplayWidth == 2)) + setHyperlink(columnToSet + 1, link); + else if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) + setHyperlink(columnToSet + 1, 0); + } + } + + private void setHyperlink(int column, int link) { + if (mLinks == null) { + if (link == 0) return; + mLinks = new int[mColumns]; + } + mLinks[column] = link; + } + + public int getHyperlink(int column) { + return mLinks == null ? 0 : mLinks[column]; } boolean isBlank() { for (int charIndex = 0, charLen = getSpaceUsed(); charIndex < charLen; charIndex++) if (mText[charIndex] != ' ') return false; + if (mLinks != null) + for (int link : mLinks) + if (link != 0) return false; return true; } diff --git a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java index 324e0bc..5884a29 100644 --- a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java +++ b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java @@ -259,6 +259,16 @@ public void titleChanged(String oldTitle, String newTitle) { mClient.onTitleChanged(this); } + @Override + public void onCommandStateChanged(boolean running) { + mClient.onCommandStateChanged(this, running); + } + + @Override + public void onWorkingDirectoryChanged(String path) { + mClient.onWorkingDirectoryChanged(this, path); + } + public synchronized boolean isRunning() { return mShellPid != -1; } diff --git a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java index 0ffe8e4..d155135 100644 --- a/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java +++ b/third_party/termux/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java @@ -11,6 +11,10 @@ public interface TerminalSessionClient { void onTitleChanged(TerminalSession changedSession); + void onCommandStateChanged(TerminalSession changedSession, boolean running); + + void onWorkingDirectoryChanged(TerminalSession changedSession, String path); + void onSessionFinished(TerminalSession finishedSession); void onCopyTextToClipboard(TerminalSession session, String text); diff --git a/third_party/termux/terminal-emulator/src/test/java/com/termux/terminal/HyperlinkTest.java b/third_party/termux/terminal-emulator/src/test/java/com/termux/terminal/HyperlinkTest.java new file mode 100644 index 0000000..2f7ec81 --- /dev/null +++ b/third_party/termux/terminal-emulator/src/test/java/com/termux/terminal/HyperlinkTest.java @@ -0,0 +1,150 @@ +package com.termux.terminal; + +/** OSC 8 metadata follows terminal cells without opening or validating URLs. */ +public class HyperlinkTest extends TerminalTestCase { + private static final String CLOSE = "\033]8;;\033\\"; + + private String open(String params, String uri) { + return "\033]8;" + params + ";" + uri + "\033\\"; + } + + private int link(int row, int column) { + TerminalBuffer screen = mTerminal.getScreen(); + return screen.mLines[screen.externalToInternalRow(row)].getHyperlink(column); + } + + private String uri(int row, int column) { + return mTerminal.getHyperlinkUri(link(row, column)); + } + + public void testOpenCloseAndTerminators() { + withTerminalSized(20, 3); + enterString(open("", "https://example.com/a;b") + "label" + CLOSE + "plain"); + assertEquals("https://example.com/a;b", uri(0, 0)); + assertEquals(link(0, 0), link(0, 4)); + assertNull(uri(0, 5)); + enterString("\033]8;;https://example.org\007X\033]8;;\007Y"); + assertEquals("https://example.org", uri(0, 10)); + assertNull(uri(0, 11)); + assertNull(mTerminal.getHyperlinkUri(-1)); + assertNull(mTerminal.getHyperlinkUri(TerminalEmulator.MAX_HYPERLINKS + 1)); + } + + public void testExplicitIdsAndSeparateImplicitLinks() { + withTerminalSized(10, 3); + enterString(open("id=part", "https://example.com") + "A" + CLOSE); + enterString(open("ignored=value:id=part", "https://example.com") + "B" + CLOSE); + assertEquals(link(0, 0), link(0, 1)); + enterString(open("id=part", "https://example.org") + "C" + CLOSE); + assertTrue(link(0, 0) != link(0, 2)); + enterString(open("", "https://example.com") + "D" + CLOSE); + enterString(open("", "https://example.com") + "E" + CLOSE); + assertTrue(link(0, 3) != link(0, 4)); + } + + public void testWideCharactersOverwriteAndCombining() { + withTerminalSized(10, 3); + enterString(open("", "https://example.com") + "\u754c\u0301" + CLOSE); + assertEquals(uri(0, 0), uri(0, 1)); + assertEquals("https://example.com", uri(0, 0)); + enterString("\033[1;2HX"); + assertNull(uri(0, 0)); + assertNull(uri(0, 1)); + enterString("\033[1;1H" + open("", "https://example.org") + "\u754c" + CLOSE); + enterString("\033[1;1HY"); + assertNull(uri(0, 0)); + assertNull(uri(0, 1)); + } + + public void testCombiningMarksKeepTheBaseCharactersLink() { + withTerminalSized(6, 3); + enterString(open("", "https://base.example") + "\u754c" + CLOSE + "\u0301"); + assertEquals("https://base.example", uri(0, 0)); + assertEquals(uri(0, 0), uri(0, 1)); + enterString(open("", "https://other.example") + "\u0302" + CLOSE + "x"); + assertEquals("https://base.example", uri(0, 0)); + assertEquals(uri(0, 0), uri(0, 1)); + assertNull(uri(0, 2)); + resize(3, 4); + assertEquals("https://base.example", uri(0, 0)); + assertEquals(uri(0, 0), uri(0, 1)); + } + + public void testLinkedSpacesSurviveReflowAfterUnicodeText() { + String[] prefixes = {"", "x", "\ud83d\ude00\u0301"}; + int[] columns = {0, 1, 2}; + for (int i = 0; i < prefixes.length; i++) { + withTerminalSized(8, 3); + enterString(prefixes[i] + open("", "https://spaces.example") + " " + CLOSE + "\r\nX"); + resize(6, 3); + for (int col = columns[i]; col < columns[i] + 3; col++) { + assertEquals("https://spaces.example", uri(0, col)); + } + assertNull(uri(0, columns[i] + 3)); + } + } + + public void testCopyWithinRowKeepsOriginalLinks() { + TerminalRow row = new TerminalRow(6, TextStyle.NORMAL); + for (int col = 0; col < 6; col++) row.setChar(col, 'a' + col, TextStyle.NORMAL, col + 1); + row.copyInterval(row, 0, 4, 2); + assertEquals(1, row.getHyperlink(2)); + assertEquals(2, row.getHyperlink(3)); + assertEquals(3, row.getHyperlink(4)); + assertEquals(4, row.getHyperlink(5)); + row.copyInterval(row, 2, 6, 0); + for (int col = 0; col < 4; col++) assertEquals(col + 1, row.getHyperlink(col)); + row.clear(TextStyle.NORMAL); + for (int col = 0; col < 6; col++) assertEquals(0, row.getHyperlink(col)); + } + + public void testLinksSurviveReflowAndHistory() { + withTerminalSized(6, 3); + enterString(open("", "https://example.com") + "abcdef" + CLOSE); + resize(3, 4); + assertEquals("https://example.com", uri(0, 0)); + assertEquals("https://example.com", uri(1, 2)); + enterString("\r\n1\r\n2\r\n3\r\n4"); + assertEquals("https://example.com", uri(-2, 0)); + assertEquals("https://example.com", uri(-1, 2)); + } + + public void testEraseAndResetCloseLinks() { + withTerminalSized(10, 3); + enterString(open("", "https://example.com") + "abc" + CLOSE); + enterString("\033[2K"); + for (int col = 0; col < 10; col++) assertNull(uri(0, col)); + enterString(open("", "https://example.org")); + mTerminal.reset(); + enterString("X"); + assertNull(uri(mTerminal.getCursorRow(), mTerminal.getCursorCol() - 1)); + } + + public void testFullTableDoesNotRetargetLiveCells() { + withTerminalSized(TerminalEmulator.MAX_HYPERLINKS + 2, 2); + for (int i = 0; i < TerminalEmulator.MAX_HYPERLINKS; i++) { + enterString(open("", "https://example.com/" + i) + "x" + CLOSE); + } + enterString(open("", "https://overflow.example") + "z" + CLOSE); + assertNull(uri(0, TerminalEmulator.MAX_HYPERLINKS)); + assertEquals("https://example.com/0", uri(0, 0)); + assertEquals("https://example.com/1023", uri(0, 1023)); + enterString("\033[1;1H "); + for (int i = 0; i < TerminalEmulator.MAX_HYPERLINKS / 4; i++) { + enterString(open("id=replacement", "https://replacement.example") + CLOSE); + } + enterString("\033[1;1H" + open("id=replacement", "https://replacement.example") + "r" + CLOSE); + assertEquals("https://replacement.example", uri(0, 0)); + assertEquals("https://example.com/1", uri(0, 1)); + assertEquals("https://example.com/1023", uri(0, 1023)); + } + + public void testAlternateBufferDoesNotReplaceMainBufferLinks() { + withTerminalSized(10, 3); + enterString(open("id=main", "https://main.example") + "M" + CLOSE); + enterString("\033[?1049h\033[H" + open("id=alt", "https://alt.example") + "A" + CLOSE); + assertEquals("https://alt.example", uri(0, 0)); + enterString("\033[?1049l"); + assertEquals("https://main.example", uri(0, 0)); + } +} diff --git a/third_party/termux/terminal-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java b/third_party/termux/terminal-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java index 13377c0..f7c9b33 100644 --- a/third_party/termux/terminal-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java +++ b/third_party/termux/terminal-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java @@ -9,6 +9,40 @@ /** "ESC ]" is the Operating System Command. */ public class OperatingSystemControlTest extends TerminalTestCase { + public void testSemanticPromptCommandState() { + withTerminalSized(10, 10); + + enterString("\033]133;A\007\033]133;B\033\\"); + assertTrue(mOutput.commandStateChanges.isEmpty()); + + enterString("\033]133;C\007\033]133;C;ignored\033\\"); + assertEquals(1, mOutput.commandStateChanges.size()); + assertTrue(mOutput.commandStateChanges.get(0)); + + enterString("\033]133;D;7\033\\"); + assertEquals(2, mOutput.commandStateChanges.size()); + assertFalse(mOutput.commandStateChanges.get(1)); + + enterString("\033]133;invalid\007"); + assertEquals(2, mOutput.commandStateChanges.size()); + } + + public void testWorkingDirectory() { + withTerminalSized(10, 10); + + enterString("\033]7;file://localhost/root/hello%20world\007"); + enterString("\033]7;kitty-shell-cwd://localhost/home/用户 + notes\033\\"); + enterString("\033]7;kitty-shell-cwd://localhost/home/100%25 ready\033\\"); + assertEquals(List.of("/root/hello world", "/home/用户 + notes", "/home/100% ready"), mOutput.workingDirectoryChanges); + + enterString("\033]7;file://example.com/root\007"); + enterString("\033]7;file://localhost/root/../etc\007"); + enterString("\033]7;file://localhost/root/%1Bbad\007"); + enterString("\033]7;file://localhost/root/%invalid\007"); + enterString("\033]7;/root\007"); + assertEquals(3, mOutput.workingDirectoryChanges.size()); + } + public void testSetTitle() throws Exception { List expectedTitleChanges = new ArrayList<>(); diff --git a/third_party/termux/terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java b/third_party/termux/terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java index 4490207..114aad3 100644 --- a/third_party/termux/terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java +++ b/third_party/termux/terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java @@ -20,6 +20,8 @@ public static class MockTerminalOutput extends TerminalOutput { final ByteArrayOutputStream baos = new ByteArrayOutputStream(); public final List titleChanges = new ArrayList<>(); public final List clipboardPuts = new ArrayList<>(); + public final List commandStateChanges = new ArrayList<>(); + public final List workingDirectoryChanges = new ArrayList<>(); public int bellsRung = 0; public int colorsChanged = 0; @@ -39,6 +41,16 @@ public void titleChanged(String oldTitle, String newTitle) { titleChanges.add(new ChangedTitle(oldTitle, newTitle)); } + @Override + public void onCommandStateChanged(boolean running) { + commandStateChanges.add(running); + } + + @Override + public void onWorkingDirectoryChanged(String path) { + workingDirectoryChanges.add(path); + } + @Override public void onCopyTextToClipboard(String text) { clipboardPuts.add(text); diff --git a/third_party/termux/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java b/third_party/termux/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java index a4bef7d..35578f8 100644 --- a/third_party/termux/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java +++ b/third_party/termux/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -20,6 +20,7 @@ public final class TerminalRenderer { final int mTextSize; final Typeface mTypeface; + final float mLineSpacingMultiplier; private final Paint mTextPaint = new Paint(); /** The width of a single mono spaced character obtained by {@link Paint#measureText(String)} on a single 'X'. */ @@ -34,14 +35,19 @@ public final class TerminalRenderer { private final float[] asciiMeasures = new float[127]; public TerminalRenderer(int textSize, Typeface typeface) { + this(textSize, typeface, 1.0f); + } + + public TerminalRenderer(int textSize, Typeface typeface, float lineSpacingMultiplier) { mTextSize = textSize; mTypeface = typeface; + mLineSpacingMultiplier = Math.max(0.5f, Math.min(2.0f, lineSpacingMultiplier)); mTextPaint.setTypeface(typeface); mTextPaint.setAntiAlias(true); mTextPaint.setTextSize(textSize); - mFontLineSpacing = (int) Math.ceil(mTextPaint.getFontSpacing()); + mFontLineSpacing = (int) Math.ceil(mTextPaint.getFontSpacing() * mLineSpacingMultiplier); mFontAscent = (int) Math.ceil(mTextPaint.ascent()); mFontLineSpacingAndAscent = mFontLineSpacing + mFontAscent; mFontWidth = mTextPaint.measureText("X"); @@ -87,6 +93,7 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, long lastRunStyle = 0; boolean lastRunInsideCursor = false; boolean lastRunInsideSelection = false; + boolean lastRunHyperlink = false; int lastRunStartColumn = -1; int lastRunStartIndex = 0; boolean lastRunFontWidthMismatch = false; @@ -102,6 +109,7 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, final boolean insideCursor = (cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1)); final boolean insideSelection = column >= selx1 && column <= selx2; final long style = lineObject.getStyle(column); + final boolean hyperlink = lineObject.getHyperlink(column) != 0; // Check if the measured text width for this code point is not the same as that expected by wcwidth(). // This could happen for some fonts which are not truly monospace, or for more exotic characters such as @@ -111,7 +119,7 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, currentCharIndex, charsForCodePoint); final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01; - if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection || fontWidthMismatch || lastRunFontWidthMismatch) { + if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection || hyperlink != lastRunHyperlink || fontWidthMismatch || lastRunFontWidthMismatch) { if (column == 0) { // Skip first column as there is nothing to draw, just record the current style. } else { @@ -124,12 +132,13 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, } drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun, measuredWidthForRun, - cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection, lastRunHyperlink); } measuredWidthForRun = 0.f; lastRunStyle = style; lastRunInsideCursor = insideCursor; lastRunInsideSelection = insideSelection; + lastRunHyperlink = hyperlink; lastRunStartColumn = column; lastRunStartIndex = currentCharIndex; lastRunFontWidthMismatch = fontWidthMismatch; @@ -152,18 +161,18 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, invertCursorTextColor = true; } drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun, - measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection, lastRunHyperlink); } } private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int startColumn, int runWidthColumns, int startCharIndex, int runWidthChars, float mes, int cursor, int cursorStyle, - long textStyle, boolean reverseVideo) { + long textStyle, boolean reverseVideo, boolean hyperlink) { int foreColor = TextStyle.decodeForeColor(textStyle); final int effect = TextStyle.decodeEffect(textStyle); int backColor = TextStyle.decodeBackColor(textStyle); final boolean bold = (effect & (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0; - final boolean underline = (effect & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0; + final boolean underline = hyperlink || (effect & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0; final boolean italic = (effect & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0; final boolean strikeThrough = (effect & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0; final boolean dim = (effect & TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0; diff --git a/third_party/termux/terminal-view/src/main/java/com/termux/view/TerminalView.java b/third_party/termux/terminal-view/src/main/java/com/termux/view/TerminalView.java index 351f47a..2e99119 100644 --- a/third_party/termux/terminal-view/src/main/java/com/termux/view/TerminalView.java +++ b/third_party/termux/terminal-view/src/main/java/com/termux/view/TerminalView.java @@ -496,12 +496,22 @@ public void onContextMenuClosed(Menu menu) { * @param textSize the new font size, in density-independent pixels. */ public void setTextSize(int textSize) { - mRenderer = new TerminalRenderer(textSize, mRenderer == null ? Typeface.MONOSPACE : mRenderer.mTypeface); + mRenderer = new TerminalRenderer( + textSize, + mRenderer == null ? Typeface.MONOSPACE : mRenderer.mTypeface, + mRenderer == null ? 1.0f : mRenderer.mLineSpacingMultiplier + ); updateSize(); } public void setTypeface(Typeface newTypeface) { - mRenderer = new TerminalRenderer(mRenderer.mTextSize, newTypeface); + mRenderer = new TerminalRenderer(mRenderer.mTextSize, newTypeface, mRenderer.mLineSpacingMultiplier); + updateSize(); + invalidate(); + } + + public void setTextAppearance(int textSize, Typeface typeface, float lineSpacingMultiplier) { + mRenderer = new TerminalRenderer(textSize, typeface, lineSpacingMultiplier); updateSize(); invalidate(); }