diff --git a/app/src/main/java/com/redtermapp/distro/DistroRunner.kt b/app/src/main/java/com/redtermapp/distro/DistroRunner.kt
index 93b69f1..f76c37f 100644
--- a/app/src/main/java/com/redtermapp/distro/DistroRunner.kt
+++ b/app/src/main/java/com/redtermapp/distro/DistroRunner.kt
@@ -2,6 +2,7 @@ package com.redtermapp.distro
import android.content.Context
import com.redtermapp.R
+import com.redtermapp.util.ClipboardBridge
import android.os.Build
import android.util.Log
import java.io.File
@@ -55,6 +56,11 @@ class DistroRunner(private val context: Context) {
}
File(rootfsDir, "tmp").mkdirs()
installer.refreshNetworkConfig(distroName)
+ val clipHost = ClipboardBridge.hostDir(context)
+ val clipGuest = clipHost?.let { ClipboardBridge.guestPath(it.absolutePath) }
+ if (clipGuest != null) {
+ ClipboardBridge.installShims(rootfsDir, clipGuest)
+ }
val launcher = ProotLaunch.writeLauncher(
context = context,
rootfsDir = rootfsDir,
@@ -62,7 +68,8 @@ class DistroRunner(private val context: Context) {
command = command,
// Its own script: sharing launch.sh with the terminal meant a
// background update could rewrite the file the terminal was starting.
- scriptName = "command-run.sh"
+ scriptName = "command-run.sh",
+ clipboardGuestDir = clipGuest
)
return try {
val process = ProcessBuilder("/system/bin/sh", launcher.absolutePath)
diff --git a/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt b/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt
index d783711..68029af 100644
--- a/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt
+++ b/app/src/main/java/com/redtermapp/distro/ProotLaunch.kt
@@ -36,7 +36,8 @@ object ProotLaunch {
startInner: String? = null,
command: String? = null,
scriptName: String = "launch.sh",
- mergeStderr: Boolean = true
+ mergeStderr: Boolean = true,
+ clipboardGuestDir: String? = null
): File {
val out = File(context.filesDir, scriptName)
out.parentFile?.mkdirs()
@@ -47,7 +48,8 @@ object ProotLaunch {
hasLoader32 = File("${context.applicationInfo.nativeLibraryDir}/libproot-loader32.so").exists(),
startInner = startInner,
command = command,
- mergeStderr = mergeStderr
+ mergeStderr = mergeStderr,
+ clipboardGuestDir = clipboardGuestDir
)
)
out.setExecutable(true, true)
@@ -61,7 +63,8 @@ object ProotLaunch {
hasLoader32: Boolean,
startInner: String? = null,
command: String? = null,
- mergeStderr: Boolean = true
+ mergeStderr: Boolean = true,
+ clipboardGuestDir: String? = null
): String {
return buildScriptText(
nativeLibDir = nativeLibDir,
@@ -69,7 +72,8 @@ object ProotLaunch {
hasLoader32 = hasLoader32,
startInner = startInner,
command = command,
- mergeStderr = mergeStderr
+ mergeStderr = mergeStderr,
+ clipboardGuestDir = clipboardGuestDir
)
}
@@ -79,7 +83,8 @@ object ProotLaunch {
hasLoader32: Boolean,
startInner: String?,
command: String?,
- mergeStderr: Boolean
+ mergeStderr: Boolean,
+ clipboardGuestDir: String?
): String {
val prootBin = "$nativeLibDir/libproot.so"
val prootLoader = "$nativeLibDir/libproot-loader.so"
@@ -92,6 +97,15 @@ object ProotLaunch {
// middle of an SFTP packet stream desynchronises it permanently. Callers
// that speak a protocol turn this off and read stderr themselves.
val redirect = if (mergeStderr) " 2>&1" else ""
+ // The clipboard bridge directory, if this session is wired to one:
+ // the xsel/xclip shims in the rootfs write here and the app side
+ // moves the content onto the system clipboard. The path is the
+ // host's, because /sdcard binds onto /sdcard.
+ val clip =
+ if (clipboardGuestDir != null) {
+ "export REDTERM_CLIPDIR=${quoteForShell(clipboardGuestDir)}\n" +
+ "mkdir -p ${quoteForShell(clipboardGuestDir)}\n"
+ } else ""
val tail = if (command == null) {
"/system/bin/sh -i" + redirect
} else {
@@ -118,6 +132,7 @@ export PATH=/system/bin:/system/xbin:/bin:/sbin:/usr/local/sbin:/usr/local/bin:/
export ENV=/root/.startup
export TERM=xterm-256color
export COLORTERM=truecolor
+$clip
if command -v locale >/dev/null 2>&1 && locale -a 2>/dev/null | grep -qi '^C\.utf-\?8$'; then
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
diff --git a/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt b/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt
index fe40e2c..afbb983 100644
--- a/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt
+++ b/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt
@@ -1,5 +1,7 @@
package com.redtermapp.ui
+import android.content.ClipData
+import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
@@ -35,6 +37,7 @@ import com.redtermapp.mosh.MoshBootstrap
import com.redtermapp.mosh.MoshClient
import com.redtermapp.mosh.MoshNative
import com.redtermapp.util.AppLog
+import com.redtermapp.util.ClipboardBridge
import com.redtermapp.util.SshClient
import com.redtermapp.distro.DistroInstaller
import com.redtermapp.distro.ProotLaunch
@@ -211,6 +214,34 @@ class TerminalActivity : AppCompatActivity() {
}
}
+ private val clipboardHandler = android.os.Handler(Looper.getMainLooper())
+ private val clipboardRunnable = object : Runnable {
+ override fun run() {
+ pumpClipboardBridge()
+ clipboardHandler.postDelayed(this, 1000)
+ }
+ }
+
+ /**
+ * The app half of [ClipboardBridge]: whatever a
+ * session's xsel/xclip wrote into the protocol directory goes onto the
+ * system clipboard here. Runs only while resumed, because
+ * setPrimaryClip matters only while this app is focused — the moment
+ * the user opens a browser to paste, the code is already in the paste
+ * box. A set missed while backgrounded, or across a process death, is
+ * picked up by the first pump on resume, because the protocol files
+ * outlive the process.
+ */
+ private fun pumpClipboardBridge() {
+ val dir = ClipboardBridge.hostDir(applicationContext) ?: return
+ val data = ClipboardBridge.consume(dir) ?: return
+ if (data.isEmpty()) return
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
+ ?: return
+ clipboard.setPrimaryClip(ClipData.newPlainText("RedTerm", data))
+ Toast.makeText(this, R.string.clipboard_bridge_copied, Toast.LENGTH_SHORT).show()
+ }
+
private fun wireBackend(backend: TerminalBackend) {
backend.onSessionFinished = { finishedSession -> handleSessionFinished(finishedSession) }
backend.onChildOutput = { session -> onConnectWatchOutput(session) }
@@ -1187,12 +1218,23 @@ class TerminalActivity : AppCompatActivity() {
?.let { File(rootfsDir, it.removePrefix("/")) }?.absolutePath
?: filesDir.absolutePath
+ // The clipboard bridge: xsel/xclip shims in the rootfs write the
+ // content into the app's own external storage, where the app side
+ // moves it onto the system clipboard. Off when there is no
+ // external storage, which is the same as before the bridge existed.
+ val clipHost = ClipboardBridge.hostDir(applicationContext)
+ val clipGuest = clipHost?.let { ClipboardBridge.guestPath(it.absolutePath) }
+ if (clipGuest != null) {
+ ClipboardBridge.installShims(rootfsDir, clipGuest)
+ }
+
val launchSh = ProotLaunch.writeLauncher(
context = this,
rootfsDir = rootfsDir,
startInner = startInner,
command = null,
- scriptName = launcherName(id)
+ scriptName = launcherName(id),
+ clipboardGuestDir = clipGuest
)
return PreparedSession(
@@ -2365,11 +2407,16 @@ class TerminalActivity : AppCompatActivity() {
terminalView.requestFocus()
terminalView.onScreenUpdated()
titleHandler.postDelayed(titleRunnable, 1000)
+ // Pick up a clipboard set that happened while backgrounded, or in a
+ // process that no longer exists: the protocol files outlive both.
+ pumpClipboardBridge()
+ clipboardHandler.postDelayed(clipboardRunnable, 1000)
RedTermWidgetProvider.updateAll(this)
}
override fun onPause() {
titleHandler.removeCallbacks(titleRunnable)
+ clipboardHandler.removeCallbacks(clipboardRunnable)
super.onPause()
}
diff --git a/app/src/main/java/com/redtermapp/util/ClipboardBridge.kt b/app/src/main/java/com/redtermapp/util/ClipboardBridge.kt
new file mode 100644
index 0000000..0fc5443
--- /dev/null
+++ b/app/src/main/java/com/redtermapp/util/ClipboardBridge.kt
@@ -0,0 +1,126 @@
+package com.redtermapp.util
+
+import android.content.Context
+import java.io.File
+
+/**
+ * Bridges the Android clipboard into a proot distro session.
+ *
+ * Guest tools that need a clipboard (`gh auth login` prints its one-time
+ * code and tries to copy it with xsel/xclip/wl-clipboard/Termux:API) have
+ * none inside a distro: there is no X, no Wayland, and Termux:API talks to
+ * the Termux app, which this is not. So the rootfs gets `xsel`/`xclip`
+ * shims that write the content into this app's own external storage, where
+ * the guest reaches it through the `/sdcard` bind, and set a trigger
+ * marker. The app consumes the trigger and hands the content to the system
+ * clipboard, which is what a browser's paste box then reads.
+ *
+ * The protocol is files, not a socket: no port to collide with, nothing
+ * that dies with a process. A pending set the app died before consuming is
+ * picked up on the next start, because the trigger file outlives it.
+ */
+object ClipboardBridge {
+
+ /** Bumped when the shim's text changes; the marker line carries it. */
+ const val SHIM_VERSION = 1
+
+ const val TRIGGER_NAME = "trigger"
+ const val PENDING_NAME = "pending"
+ const val LAST_NAME = "last"
+
+ /**
+ * The protocol directory, on the host side. The guest sees the same
+ * path, because it lives under /sdcard, which proot binds 1:1; and the
+ * app's own external files directory is writable by the guest's
+ * processes without any all-files grant, because they run as this
+ * app's uid.
+ *
+ * Null when there is no external storage at all; then the bridge is
+ * off, which is the same as today rather than a new failure.
+ */
+ fun hostDir(context: Context): File? {
+ val root = context.getExternalFilesDir(null) ?: return null
+ val dir = File(root, "clipboard")
+ dir.mkdirs()
+ return dir
+ }
+
+ /**
+ * The path the launcher exports to the guest. It is the host path
+ * verbatim: /sdcard binds onto /sdcard, so there is no translation.
+ */
+ fun guestPath(hostPath: String): String = hostPath
+
+ /**
+ * The shim, installed as both `xsel` and `xclip` in the rootfs'
+ * /usr/local/bin, which the session's PATH puts ahead of anything the
+ * distro might ship.
+ *
+ * GET (`-o`) prints the last thing the app put on the clipboard; SET
+ * (stdin, with or without `-i`) writes it for the app to pick up.
+ * Unknown flags fall through to SET, which is the only mode that ever
+ * matters here. The fallback directory covers a session whose
+ * launcher predates the environment export.
+ */
+ fun shim(fallbackGuestDir: String): String =
+ listOf(
+ "#!/system/bin/sh",
+ "# managed by RedTerm (clipboard shim v$SHIM_VERSION); do not edit",
+ "d=\"${'$'}{REDTERM_CLIPDIR:-$fallbackGuestDir}\"",
+ "case \"${'$'}{1:-}\" in",
+ " -o|--output)",
+ " cat \"${'$'}d/$LAST_NAME\" 2>/dev/null",
+ " exit 0",
+ " ;;",
+ " *)",
+ " if [ -t 0 ]; then",
+ " echo \"${'$'}0: nothing to copy: no input (get the clipboard with '${'$'}0 -o')\" 1>&2",
+ " exit 1",
+ " fi",
+ " cat > \"${'$'}d/$PENDING_NAME\" || exit 1",
+ " : > \"${'$'}d/$TRIGGER_NAME\"",
+ " exit 0",
+ " ;;",
+ "esac",
+ ).joinToString("\n") + "\n"
+
+ /**
+ * Writes the shims into the rootfs' /usr/local/bin, executable, and
+ * only when the content differs from what is there. That is the rule
+ * for a managed file: an older RedTerm shim is rewritten on the next
+ * session start, which is how a corrected shim reaches an install that
+ * already has one; the marker in the first lines says which hand wrote
+ * the file.
+ */
+ fun installShims(rootfsDir: File, fallbackGuestDir: String) {
+ val bin = File(rootfsDir, "usr/local/bin")
+ bin.mkdirs()
+ val script = shim(fallbackGuestDir)
+ for (name in listOf("xsel", "xclip")) {
+ val file = File(bin, name)
+ val current = file.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() }
+ if (current != script) {
+ file.writeText(script)
+ }
+ file.setExecutable(true, false)
+ }
+ }
+
+ /**
+ * One step of the consumption loop: if the guest set something, move it
+ * to [LAST_NAME] for the next GET and return it, otherwise null. A
+ * trigger with no pending file (a kill mid-write) is swept, not an
+ * error: there is nothing to deliver and the marker must not pile up.
+ */
+ fun consume(dir: File): String? {
+ val trigger = File(dir, TRIGGER_NAME)
+ if (!trigger.exists()) return null
+ val pending = File(dir, PENDING_NAME)
+ val data = if (pending.isFile) runCatching { pending.readText() }.getOrNull() else null
+ if (data != null) {
+ pending.copyTo(File(dir, LAST_NAME), overwrite = true)
+ }
+ trigger.delete()
+ return data
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 263af45..e55d5e1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -528,6 +528,7 @@
Delete all recorded crash reports?
Crash reports deleted
Crash report copied to clipboard
+ Copied to clipboard
Saved to %1$s
Copy
Download
diff --git a/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt b/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt
index f6b9a21..6b4fa2e 100644
--- a/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt
+++ b/app/src/test/java/com/redtermapp/distro/ProotLaunchTest.kt
@@ -85,13 +85,32 @@ class ProotLaunchTest {
assertFalse(withoutLoader.contains("PROOT_LOADER_32"))
}
+ @Test
+ fun `clipboard bridge export appears only when requested`() {
+ val withBridge = ProotLaunch.buildScript(
+ nativeLibDir, rootfsPath, hasLoader32 = true,
+ clipboardGuestDir = "/sdcard/Android/data/com.redtermapp/files/clipboard"
+ )
+ assertTrue(
+ withBridge.contains(
+ "export REDTERM_CLIPDIR='/sdcard/Android/data/com.redtermapp/files/clipboard'"
+ )
+ )
+ assertTrue(withBridge.contains("mkdir -p '/sdcard/Android/data/com.redtermapp/files/clipboard'"))
+ assertFalse("the bridge must be opt-in", interactive().contains("REDTERM_CLIPDIR"))
+ }
+
@Test
fun `generated scripts are valid shell`() {
val outDir = File("build/proot-launch-scripts").apply { mkdirs() }
val scripts = mapOf(
"interactive" to interactive("/root"),
"one-shot" to oneShot("apt-get update -y"),
- "no-loader" to ProotLaunch.buildScript(nativeLibDir, rootfsPath, hasLoader32 = false)
+ "no-loader" to ProotLaunch.buildScript(nativeLibDir, rootfsPath, hasLoader32 = false),
+ "bridge" to ProotLaunch.buildScript(
+ nativeLibDir, rootfsPath, hasLoader32 = true,
+ clipboardGuestDir = "/sdcard/Android/data/com.redtermapp/files/clipboard"
+ )
)
scripts.forEach { (name, body) ->
val file = File(outDir, "$name.sh")
diff --git a/app/src/test/java/com/redtermapp/util/ClipboardBridgeTest.kt b/app/src/test/java/com/redtermapp/util/ClipboardBridgeTest.kt
new file mode 100644
index 0000000..699968f
--- /dev/null
+++ b/app/src/test/java/com/redtermapp/util/ClipboardBridgeTest.kt
@@ -0,0 +1,149 @@
+package com.redtermapp.util
+
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+/**
+ * The guest half of the clipboard bridge is a shell file, so it is run, not
+ * read: the SET and GET paths go through a real /bin/sh with the shim's
+ * text, and the app half is checked on real files.
+ */
+class ClipboardBridgeTest {
+
+ private val scratch = ArrayList()
+
+ private fun tempDir(name: String): File =
+ File(System.getProperty("java.io.tmpdir"), "redterm-$name-${System.nanoTime()}")
+ .apply { mkdirs(); scratch.add(this) }
+
+ @After
+ fun clean() {
+ scratch.forEach { it.deleteRecursively() }
+ }
+
+ private fun runSh(
+ args: List,
+ env: Map = emptyMap(),
+ stdin: String? = null
+ ): Pair {
+ val out = ByteArrayOutputStream()
+ val builder = ProcessBuilder(listOf("/bin/sh") + args)
+ for ((k, v) in env) builder.environment()[k] = v
+ var stdinFile: File? = null
+ if (stdin != null) {
+ stdinFile = tempDir("cb-stdin").let { d -> File(d, "in").apply { writeText(stdin) } }
+ builder.redirectInput(ProcessBuilder.Redirect.from(stdinFile))
+ }
+ builder.redirectOutput(ProcessBuilder.Redirect.PIPE)
+ val p = builder.start()
+ out.write(p.inputStream.readBytes())
+ val exited = p.waitFor(30, TimeUnit.SECONDS)
+ if (!exited) {
+ p.destroyForcibly()
+ throw AssertionError("sh run did not exit: $args")
+ }
+ return p.exitValue() to out.toString(Charsets.UTF_8)
+ }
+
+ @Test
+ fun `shim starts with a shebang and carries the managed marker`() {
+ val s = ClipboardBridge.shim("/sd/clip")
+ assertTrue(s.startsWith("#!/system/bin/sh\n"))
+ assertTrue(s.contains("# managed by RedTerm (clipboard shim v${ClipboardBridge.SHIM_VERSION})"))
+ assertTrue(s.contains("d=\"${'$'}{REDTERM_CLIPDIR:-/sd/clip}\""))
+ }
+
+ @Test
+ fun `set then consume then get round-trips through a real shell`() {
+ val clip = tempDir("cb-clip")
+ val bin = tempDir("cb-bin")
+ val xsel = File(bin, "xsel").apply {
+ writeText(ClipboardBridge.shim(clip.absolutePath))
+ setExecutable(true)
+ }
+
+ val (setCode, _) = runSh(
+ listOf(xsel.absolutePath, "-i"),
+ env = mapOf("REDTERM_CLIPDIR" to clip.absolutePath),
+ stdin = "CODE-1234"
+ )
+ assertEquals("SET must exit 0", 0, setCode)
+ assertEquals("CODE-1234", File(clip, ClipboardBridge.PENDING_NAME).readText())
+ assertTrue(File(clip, ClipboardBridge.TRIGGER_NAME).exists())
+
+ assertEquals("CODE-1234", ClipboardBridge.consume(clip))
+ assertFalse(File(clip, ClipboardBridge.TRIGGER_NAME).exists())
+ assertEquals("CODE-1234", File(clip, ClipboardBridge.LAST_NAME).readText())
+
+ val (getCode, getOut) = runSh(
+ listOf(xsel.absolutePath, "-o"),
+ env = mapOf("REDTERM_CLIPDIR" to clip.absolutePath)
+ )
+ assertEquals(0, getCode)
+ assertEquals("CODE-1234", getOut)
+ }
+
+ @Test
+ fun `set with empty input still delivers, as an empty clipboard`() {
+ val clip = tempDir("cb-clip-empty")
+ val xsel = File(tempDir("cb-bin-empty"), "xsel").apply {
+ writeText(ClipboardBridge.shim(clip.absolutePath))
+ setExecutable(true)
+ }
+ val (code, _) = runSh(
+ listOf(xsel.absolutePath, "-i"),
+ env = mapOf("REDTERM_CLIPDIR" to clip.absolutePath),
+ stdin = ""
+ )
+ assertEquals(0, code)
+ assertEquals("", ClipboardBridge.consume(clip))
+ }
+
+ @Test
+ fun `get with nothing ever set prints nothing and succeeds`() {
+ val clip = tempDir("cb-clip-none")
+ val xsel = File(tempDir("cb-bin-none"), "xsel").apply {
+ writeText(ClipboardBridge.shim(clip.absolutePath))
+ setExecutable(true)
+ }
+ val (code, out) = runSh(
+ listOf(xsel.absolutePath, "-o"),
+ env = mapOf("REDTERM_CLIPDIR" to clip.absolutePath)
+ )
+ assertEquals(0, code)
+ assertEquals("", out)
+ }
+
+ @Test
+ fun `a stale trigger without a pending file is swept, not an error`() {
+ val clip = tempDir("cb-clip-stale")
+ File(clip, ClipboardBridge.TRIGGER_NAME).writeText("")
+ assertNull(ClipboardBridge.consume(clip))
+ assertFalse(File(clip, ClipboardBridge.TRIGGER_NAME).exists())
+ assertNull(ClipboardBridge.consume(clip))
+ }
+
+ @Test
+ fun `install writes both shims and rewrites a drifted file`() {
+ val rootfs = tempDir("cb-rootfs")
+ val guest = "/sdcard/Android/data/com.redtermapp/files/clipboard"
+ ClipboardBridge.installShims(rootfs, guest)
+ val expected = ClipboardBridge.shim(guest)
+ for (name in listOf("xsel", "xclip")) {
+ val f = File(rootfs, "usr/local/bin/$name")
+ assertTrue("$name missing", f.isFile)
+ assertEquals(name, expected, f.readText())
+ }
+ File(rootfs, "usr/local/bin/xsel").writeText("#!/bin/sh\necho manual\n")
+ ClipboardBridge.installShims(rootfs, guest)
+ assertEquals("a hand-written shim is replaced on the next session start",
+ expected, File(rootfs, "usr/local/bin/xsel").readText())
+ }
+}