package com.termux.terminal; import android.annotation.SuppressLint; import android.os.Handler; import android.os.Message; import android.system.ErrnoException; import android.system.Os; import android.system.OsConstants; import android.util.Log; import java.io.FileDescriptor; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.UUID; /** * A terminal session, consisting of a process coupled to a terminal interface. * <p> * The subprocess will be executed by the constructor, and when the size is made known by a call to * {@link #updateSize(int, int)} terminal emulation will begin and threads will be spawned to handle the subprocess I/O. * All terminal emulation and callback methods will be performed on the main thread. * <p> * The child process may be exited forcefully by using the {@link #finishIfRunning()} method. * <p> * NOTE: The terminal session may outlive the EmulatorView, so be careful with callbacks! */ public final class TerminalSession extends TerminalOutput { /** Callback to be invoked when a {@link TerminalSession} changes. */ public interface SessionChangedCallback { void onTextChanged(TerminalSession changedSession); void onTitleChanged(TerminalSession changedSession); void onSessionFinished(TerminalSession finishedSession); void onClipboardText(TerminalSession session, String text); void onBell(TerminalSession session); void onColorsChanged(TerminalSession session); } private static FileDescriptor wrapFileDescriptor(int fileDescriptor) { FileDescriptor result = new FileDescriptor(); try { Field descriptorField; try { descriptorField = FileDescriptor.class.getDeclaredField("descriptor"); } catch (NoSuchFieldException e) { // For desktop java: descriptorField = FileDescriptor.class.getDeclaredField("fd"); } descriptorField.setAccessible(true); descriptorField.set(result, fileDescriptor); } catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) { Log.wtf(EmulatorDebug.LOG_TAG, "Error accessing FileDescriptor#descriptor private field", e); System.exit(1); } return result; } private static final int MSG_NEW_INPUT = 1; private static final int MSG_PROCESS_EXITED = 4; public final String mHandle = UUID.randomUUID().toString(); TerminalEmulator mEmulator; /** * A queue written to from a separate thread when the process outputs, and read by main thread to process by * terminal emulator. */ final ByteQueue mProcessToTerminalIOQueue = new ByteQueue(4096); /** * A queue written to from the main thread due to user interaction, and read by another thread which forwards by * writing to the {@link #mTerminalFileDescriptor}. */ final ByteQueue mTerminalToProcessIOQueue = new ByteQueue(4096); /** Buffer to write translate code points into utf8 before writing to mTerminalToProcessIOQueue */ private final byte[] mUtf8InputBuffer = new byte[5]; /** Callback which gets notified when a session finishes or changes title. */ final SessionChangedCallback mChangeCallback; /** The pid of the shell process. 0 if not started and -1 if finished running. */ int mShellPid; /** The exit status of the shell process. Only valid if ${@link #mShellPid} is -1. */ int mShellExitStatus; /** * The file descriptor referencing the master half of a pseudo-terminal pair, resulting from calling * {@link JNI#createSubprocess(String, String, String[], String[], int[], int, int)}. */ private int mTerminalFileDescriptor; /** Set by the application for user identification of session, not by terminal. */ public String mSessionName; @SuppressLint("HandlerLeak") final Handler mMainThreadHandler = new Handler() { final byte[] mReceiveBuffer = new byte[4 * 1024]; @Override public void handleMessage(Message msg) { if (msg.what == MSG_NEW_INPUT && isRunning()) { int bytesRead = mProcessToTerminalIOQueue.read(mReceiveBuffer, false); if (bytesRead > 0) { mEmulator.append(mReceiveBuffer, bytesRead); notifyScreenUpdate(); } } else if (msg.what == MSG_PROCESS_EXITED) { int exitCode = (Integer) msg.obj; cleanupResources(exitCode); mChangeCallback.onSessionFinished(TerminalSession.this); String exitDescription = "\r\n[Process completed"; if (exitCode > 0) { // Non-zero process exit. exitDescription += " (code " + exitCode + ")"; } else if (exitCode < 0) { // Negated signal. exitDescription += " (signal " + (-exitCode) + ")"; } exitDescription += " - press Enter]"; byte[] bytesToWrite = exitDescription.getBytes(StandardCharsets.UTF_8); mEmulator.append(bytesToWrite, bytesToWrite.length); notifyScreenUpdate(); } } }; private final String mShellPath; private final String mCwd; private final String[] mArgs; private final String[] mEnv; public TerminalSession(String shellPath, String cwd, String[] args, String[] env, SessionChangedCallback changeCallback) { mChangeCallback = changeCallback; this.mShellPath = shellPath; this.mCwd = cwd; this.mArgs = args; this.mEnv = env; } /** Inform the attached pty of the new size and reflow or initialize the emulator. */ public void updateSize(int columns, int rows) { if (mEmulator == null) { initializeEmulator(columns, rows); } else { JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns); mEmulator.resize(columns, rows); } } /** The terminal title as set through escape sequences or null if none set. */ public String getTitle() { return (mEmulator == null) ? null : mEmulator.getTitle(); } /** * Set the terminal emulator's window size and start terminal emulation. * * @param columns The number of columns in the terminal window. * @param rows The number of rows in the terminal window. */ public void initializeEmulator(int columns, int rows) { mEmulator = new TerminalEmulator(this, columns, rows, /* transcript= */2000); int[] processId = new int[1]; mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns); mShellPid = processId[0]; final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor); new Thread("TermSessionInputReader[pid=" + mShellPid + "]") { @Override public void run() { try (InputStream termIn = new FileInputStream(terminalFileDescriptorWrapped)) { final byte[] buffer = new byte[4096]; while (true) { int read = termIn.read(buffer); if (read == -1) return; if (!mProcessToTerminalIOQueue.write(buffer, 0, read)) return; mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT); } } catch (Exception e) { // Ignore, just shutting down. } } }.start(); new Thread("TermSessionOutputWriter[pid=" + mShellPid + "]") { @Override public void run() { final byte[] buffer = new byte[4096]; try (FileOutputStream termOut = new FileOutputStream(terminalFileDescriptorWrapped)) { while (true) { int bytesToWrite = mTerminalToProcessIOQueue.read(buffer, true); if (bytesToWrite == -1) return; termOut.write(buffer, 0, bytesToWrite); } } catch (IOException e) { // Ignore. } } }.start(); new Thread("TermSessionWaiter[pid=" + mShellPid + "]") { @Override public void run() { int processExitCode = JNI.waitFor(mShellPid); mMainThreadHandler.sendMessage(mMainThreadHandler.obtainMessage(MSG_PROCESS_EXITED, processExitCode)); } }.start(); } /** Write data to the shell process. */ @Override public void write(byte[] data, int offset, int count) { if (mShellPid > 0) mTerminalToProcessIOQueue.write(data, offset, count); } /** Write the Unicode code point to the terminal encoded in UTF-8. */ public void writeCodePoint(boolean prependEscape, int codePoint) { if (codePoint > 1114111 || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { // 1114111 (= 2**16 + 1024**2 - 1) is the highest code point, [0xD800,0xDFFF] is the surrogate range. throw new IllegalArgumentException("Invalid code point: " + codePoint); } int bufferPosition = 0; if (prependEscape) mUtf8InputBuffer[bufferPosition++] = 27; if (codePoint <= /* 7 bits */0b1111111) { mUtf8InputBuffer[bufferPosition++] = (byte) codePoint; } else if (codePoint <= /* 11 bits */0b11111111111) { /* 110xxxxx leading byte with leading 5 bits */ mUtf8InputBuffer[bufferPosition++] = (byte) (0b11000000 | (codePoint >> 6)); /* 10xxxxxx continuation byte with following 6 bits */ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); } else if (codePoint <= /* 16 bits */0b1111111111111111) { /* 1110xxxx leading byte with leading 4 bits */ mUtf8InputBuffer[bufferPosition++] = (byte) (0b11100000 | (codePoint >> 12)); /* 10xxxxxx continuation byte with following 6 bits */ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111)); /* 10xxxxxx continuation byte with following 6 bits */ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); } else { /* We have checked codePoint <= 1114111 above, so we have max 21 bits = 0b111111111111111111111 */ /* 11110xxx leading byte with leading 3 bits */ mUtf8InputBuffer[bufferPosition++] = (byte) (0b11110000 | (codePoint >> 18)); /* 10xxxxxx continuation byte with following 6 bits */ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 12) & 0b111111)); /* 10xxxxxx continuation byte with following 6 bits */ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111)); /* 10xxxxxx continuation byte with following 6 bits */ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); } write(mUtf8InputBuffer, 0, bufferPosition); } public TerminalEmulator getEmulator() { return mEmulator; } /** Notify the {@link #mChangeCallback} that the screen has changed. */ protected void notifyScreenUpdate() { mChangeCallback.onTextChanged(this); } /** Reset state for terminal emulator state. */ public void reset() { mEmulator.reset(); notifyScreenUpdate(); } /** Finish this terminal session by sending SIGKILL to the shell. */ public void finishIfRunning() { if (isRunning()) { try { Os.kill(mShellPid, OsConstants.SIGKILL); } catch (ErrnoException e) { Log.w("termux", "Failed sending SIGKILL: " + e.getMessage()); } } } /** Cleanup resources when the process exits. */ void cleanupResources(int exitStatus) { synchronized (this) { mShellPid = -1; mShellExitStatus = exitStatus; } // Stop the reader and writer threads, and close the I/O streams mTerminalToProcessIOQueue.close(); mProcessToTerminalIOQueue.close(); JNI.close(mTerminalFileDescriptor); } @Override public void titleChanged(String oldTitle, String newTitle) { mChangeCallback.onTitleChanged(this); } public synchronized boolean isRunning() { return mShellPid != -1; } /** Only valid if not {@link #isRunning()}. */ public synchronized int getExitStatus() { return mShellExitStatus; } @Override public void clipboardText(String text) { mChangeCallback.onClipboardText(this, text); } @Override public void onBell() { mChangeCallback.onBell(this); } @Override public void onColorsChanged() { mChangeCallback.onColorsChanged(this); } public int getPid() { return mShellPid; } }