mTitleStack = new Stack<>();
+
+ /** The cursor position. Between (0,0) and (mRows-1, mColumns-1). */
+ private int mCursorRow, mCursorCol;
+
+ /** The number of character rows and columns in the terminal screen. */
+ public int mRows, mColumns;
+
+ /** Size of a terminal cell in pixels. */
+ private int mCellWidthPixels, mCellHeightPixels;
+
+ /** The number of terminal transcript rows that can be scrolled back to. */
+ public static final int TERMINAL_TRANSCRIPT_ROWS_MIN = 100;
+ public static final int TERMINAL_TRANSCRIPT_ROWS_MAX = 50000;
+ public static final int DEFAULT_TERMINAL_TRANSCRIPT_ROWS = 2000;
+
+
+ /* The supported terminal cursor styles. */
+
+ public static final int TERMINAL_CURSOR_STYLE_BLOCK = 0;
+ public static final int TERMINAL_CURSOR_STYLE_UNDERLINE = 1;
+ public static final int TERMINAL_CURSOR_STYLE_BAR = 2;
+ public static final int DEFAULT_TERMINAL_CURSOR_STYLE = TERMINAL_CURSOR_STYLE_BLOCK;
+ public static final Integer[] TERMINAL_CURSOR_STYLES_LIST = new Integer[]{TERMINAL_CURSOR_STYLE_BLOCK, TERMINAL_CURSOR_STYLE_UNDERLINE, TERMINAL_CURSOR_STYLE_BAR};
+
+ /** The terminal cursor styles. */
+ private int mCursorStyle = DEFAULT_TERMINAL_CURSOR_STYLE;
+
+
+ /** The normal screen buffer. Stores the characters that appear on the screen of the emulated terminal. */
+ private final TerminalBuffer mMainBuffer;
+ /**
+ * The alternate screen buffer, exactly as large as the display and contains no additional saved lines (so that when
+ * the alternate screen buffer is active, you cannot scroll back to view saved lines).
+ *
+ * See http://www.xfree86.org/current/ctlseqs.html#The%20Alternate%20Screen%20Buffer
+ */
+ final TerminalBuffer mAltBuffer;
+ /** The current screen buffer, pointing at either {@link #mMainBuffer} or {@link #mAltBuffer}. */
+ private TerminalBuffer mScreen;
+
+ /** The terminal session this emulator is bound to. */
+ private final TerminalOutput mSession;
+
+ TerminalSessionClient mClient;
+
+ /** Keeps track of the current argument of the current escape sequence. Ranges from 0 to MAX_ESCAPE_PARAMETERS-1. */
+ private int mArgIndex;
+ /** Holds the arguments of the current escape sequence. */
+ private final int[] mArgs = new int[MAX_ESCAPE_PARAMETERS];
+ /** Holds the bit flags which arguments are sub parameters (after a colon) - bit N is set if mArgs[N] is a sub parameter. */
+ private int mArgsSubParamsBitSet = 0;
+
+ /** Holds OSC and device control arguments, which can be strings. */
+ private final StringBuilder mOSCOrDeviceControlArgs = new StringBuilder();
+
+ /**
+ * True if the current escape sequence should continue, false if the current escape sequence should be terminated.
+ * Used when parsing a single character.
+ */
+ private boolean mContinueSequence;
+
+ /** The current state of the escape sequence state machine. One of the ESC_* constants. */
+ private int mEscapeState;
+
+ private final SavedScreenState mSavedStateMain = new SavedScreenState();
+ private final SavedScreenState mSavedStateAlt = new SavedScreenState();
+
+ /** http://www.vt100.net/docs/vt102-ug/table5-15.html */
+ private boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true;
+
+ /**
+ * @see TerminalEmulator#mapDecSetBitToInternalBit(int)
+ */
+ private int mCurrentDecSetFlags, mSavedDecSetFlags;
+
+ /**
+ * If insert mode (as opposed to replace mode) is active. In insert mode new characters are inserted, pushing
+ * existing text to the right. Characters moved past the right margin are lost.
+ */
+ private boolean mInsertMode;
+
+ /** An array of tab stops. mTabStop[i] is true if there is a tab stop set for column i. */
+ private boolean[] mTabStop;
+
+ /**
+ * Top margin of screen for scrolling ranges from 0 to mRows-2. Bottom margin ranges from mTopMargin + 2 to mRows
+ * (Defines the first row after the scrolling region). Left/right margin in [0, mColumns].
+ */
+ private int mTopMargin, mBottomMargin, mLeftMargin, mRightMargin;
+
+ /**
+ * If the next character to be emitted will be automatically wrapped to the next line. Used to disambiguate the case
+ * where the cursor is positioned on the last column (mColumns-1). When standing there, a written character will be
+ * output in the last column, the cursor not moving but this flag will be set. When outputting another character
+ * this will move to the next line.
+ */
+ private boolean mAboutToAutoWrap;
+
+ /**
+ * If the cursor blinking is enabled. It requires cursor itself to be enabled, which is controlled
+ * byt whether {@link #DECSET_BIT_CURSOR_ENABLED} bit is set or not.
+ */
+ private boolean mCursorBlinkingEnabled;
+
+ /**
+ * If currently cursor should be in a visible state or not if {@link #mCursorBlinkingEnabled}
+ * is {@code true}.
+ */
+ private boolean mCursorBlinkState;
+
+ /**
+ * Current foreground, background and underline colors. Can either be a color index in [0,259] or a truecolor (24-bit) value.
+ * For a 24-bit value the top byte (0xff000000) is set.
+ *
+ *
Note that the underline color is currently parsed but not yet used during rendering.
+ *
+ * @see TextStyle
+ */
+ int mForeColor, mBackColor, mUnderlineColor;
+
+ /** Current {@link TextStyle} effect. */
+ int mEffect;
+
+ /**
+ * The number of scrolled lines since last calling {@link #clearScrollCounter()}. Used for moving selection up along
+ * with the scrolling text.
+ */
+ private int mScrollCounter = 0;
+
+ /** If automatic scrolling of terminal is disabled */
+ private boolean mAutoScrollDisabled;
+
+ private byte mUtf8ToFollow, mUtf8Index;
+ private final byte[] mUtf8InputBuffer = new byte[4];
+ private int mLastEmittedCodePoint = -1;
+
+ public final TerminalColors mColors = new TerminalColors();
+
+ private static final String LOG_TAG = "TerminalEmulator";
+
+ private boolean isDecsetInternalBitSet(int bit) {
+ return (mCurrentDecSetFlags & bit) != 0;
+ }
+
+ private void setDecsetinternalBit(int internalBit, boolean set) {
+ if (set) {
+ // The mouse modes are mutually exclusive.
+ if (internalBit == DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) {
+ setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT, false);
+ } else if (internalBit == DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT) {
+ setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE, false);
+ }
+ }
+ if (set) {
+ mCurrentDecSetFlags |= internalBit;
+ } else {
+ mCurrentDecSetFlags &= ~internalBit;
+ }
+ }
+
+ static int mapDecSetBitToInternalBit(int decsetBit) {
+ switch (decsetBit) {
+ case 1:
+ return DECSET_BIT_APPLICATION_CURSOR_KEYS;
+ case 5:
+ return DECSET_BIT_REVERSE_VIDEO;
+ case 6:
+ return DECSET_BIT_ORIGIN_MODE;
+ case 7:
+ return DECSET_BIT_AUTOWRAP;
+ case 25:
+ return DECSET_BIT_CURSOR_ENABLED;
+ case 66:
+ return DECSET_BIT_APPLICATION_KEYPAD;
+ case 69:
+ return DECSET_BIT_LEFTRIGHT_MARGIN_MODE;
+ case 1000:
+ return DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE;
+ case 1002:
+ return DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT;
+ case 1004:
+ return DECSET_BIT_SEND_FOCUS_EVENTS;
+ case 1006:
+ return DECSET_BIT_MOUSE_PROTOCOL_SGR;
+ case 2004:
+ return DECSET_BIT_BRACKETED_PASTE_MODE;
+ default:
+ return -1;
+ // throw new IllegalArgumentException("Unsupported decset: " + decsetBit);
+ }
+ }
+
+ public TerminalEmulator(TerminalOutput session, int columns, int rows, int cellWidthPixels, int cellHeightPixels, Integer transcriptRows, TerminalSessionClient client) {
+ mSession = session;
+ mScreen = mMainBuffer = new TerminalBuffer(columns, getTerminalTranscriptRows(transcriptRows), rows);
+ mAltBuffer = new TerminalBuffer(columns, rows, rows);
+ mClient = client;
+ mRows = rows;
+ mColumns = columns;
+ mCellWidthPixels = cellWidthPixels;
+ mCellHeightPixels = cellHeightPixels;
+ mTabStop = new boolean[mColumns];
+ reset();
+ }
+
+ public void updateTerminalSessionClient(TerminalSessionClient client) {
+ mClient = client;
+ setCursorStyle();
+ setCursorBlinkState(true);
+ }
+
+ public TerminalBuffer getScreen() {
+ return mScreen;
+ }
+
+ public boolean isAlternateBufferActive() {
+ return mScreen == mAltBuffer;
+ }
+
+ private int getTerminalTranscriptRows(Integer transcriptRows) {
+ if (transcriptRows == null || transcriptRows < TERMINAL_TRANSCRIPT_ROWS_MIN || transcriptRows > TERMINAL_TRANSCRIPT_ROWS_MAX)
+ return DEFAULT_TERMINAL_TRANSCRIPT_ROWS;
+ else
+ return transcriptRows;
+ }
+
+ /**
+ * @param mouseButton one of the MOUSE_* constants of this class.
+ */
+ public void sendMouseEvent(int mouseButton, int column, int row, boolean pressed) {
+ if (column < 1) column = 1;
+ if (column > mColumns) column = mColumns;
+ if (row < 1) row = 1;
+ if (row > mRows) row = mRows;
+
+ if (mouseButton == MOUSE_LEFT_BUTTON_MOVED && !isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT)) {
+ // Do not send tracking.
+ } else if (isDecsetInternalBitSet(DECSET_BIT_MOUSE_PROTOCOL_SGR)) {
+ mSession.write(String.format("\033[<%d;%d;%d" + (pressed ? 'M' : 'm'), mouseButton, column, row));
+ } else {
+ mouseButton = pressed ? mouseButton : 3; // 3 for release of all buttons.
+ // Clip to screen, and clip to the limits of 8-bit data.
+ boolean out_of_bounds = column > 255 - 32 || row > 255 - 32;
+ if (!out_of_bounds) {
+ byte[] data = {'\033', '[', 'M', (byte) (32 + mouseButton), (byte) (32 + column), (byte) (32 + row)};
+ mSession.write(data, 0, data.length);
+ }
+ }
+ }
+
+ public void resize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) {
+ this.mCellWidthPixels = cellWidthPixels;
+ this.mCellHeightPixels = cellHeightPixels;
+
+ if (mRows == rows && mColumns == columns) {
+ return;
+ } else if (columns < 2 || rows < 2) {
+ throw new IllegalArgumentException("rows=" + rows + ", columns=" + columns);
+ }
+
+ if (mRows != rows) {
+ mRows = rows;
+ mTopMargin = 0;
+ mBottomMargin = mRows;
+ }
+ if (mColumns != columns) {
+ int oldColumns = mColumns;
+ mColumns = columns;
+ boolean[] oldTabStop = mTabStop;
+ mTabStop = new boolean[mColumns];
+ setDefaultTabStops();
+ int toTransfer = Math.min(oldColumns, columns);
+ System.arraycopy(oldTabStop, 0, mTabStop, 0, toTransfer);
+ mLeftMargin = 0;
+ mRightMargin = mColumns;
+ }
+
+ resizeScreen();
+ }
+
+ private void resizeScreen() {
+ final int[] cursor = {mCursorCol, mCursorRow};
+ int newTotalRows = (mScreen == mAltBuffer) ? mRows : mMainBuffer.mTotalRows;
+ mScreen.resize(mColumns, mRows, newTotalRows, cursor, getStyle(), isAlternateBufferActive());
+ mCursorCol = cursor[0];
+ mCursorRow = cursor[1];
+ }
+
+ public int getCursorRow() {
+ return mCursorRow;
+ }
+
+ public int getCursorCol() {
+ return mCursorCol;
+ }
+
+ /** Get the terminal cursor style. It will be one of {@link #TERMINAL_CURSOR_STYLES_LIST} */
+ public int getCursorStyle() {
+ return mCursorStyle;
+ }
+
+ /** Set the terminal cursor style. */
+ public void setCursorStyle() {
+ Integer cursorStyle = null;
+
+ if (mClient != null)
+ cursorStyle = mClient.getTerminalCursorStyle();
+
+ if (cursorStyle == null || !Arrays.asList(TERMINAL_CURSOR_STYLES_LIST).contains(cursorStyle))
+ mCursorStyle = DEFAULT_TERMINAL_CURSOR_STYLE;
+ else
+ mCursorStyle = cursorStyle;
+ }
+
+ public boolean isReverseVideo() {
+ return isDecsetInternalBitSet(DECSET_BIT_REVERSE_VIDEO);
+ }
+
+
+
+ public boolean isCursorEnabled() {
+ return isDecsetInternalBitSet(DECSET_BIT_CURSOR_ENABLED);
+ }
+ public boolean shouldCursorBeVisible() {
+ if (!isCursorEnabled())
+ return false;
+ else
+ return mCursorBlinkingEnabled ? mCursorBlinkState : true;
+ }
+
+ public void setCursorBlinkingEnabled(boolean cursorBlinkingEnabled) {
+ this.mCursorBlinkingEnabled = cursorBlinkingEnabled;
+ }
+
+ public void setCursorBlinkState(boolean cursorBlinkState) {
+ this.mCursorBlinkState = cursorBlinkState;
+ }
+
+
+
+ public boolean isKeypadApplicationMode() {
+ return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD);
+ }
+
+ public boolean isCursorKeysApplicationMode() {
+ return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS);
+ }
+
+ /** If mouse events are being sent as escape codes to the terminal. */
+ public boolean isMouseTrackingActive() {
+ return isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) || isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT);
+ }
+
+ private void setDefaultTabStops() {
+ for (int i = 0; i < mColumns; i++)
+ mTabStop[i] = (i & 7) == 0 && i != 0;
+ }
+
+ /**
+ * Accept bytes (typically from the pseudo-teletype) and process them.
+ *
+ * @param buffer a byte array containing the bytes to be processed
+ * @param length the number of bytes in the array to process
+ */
+ public void append(byte[] buffer, int length) {
+ for (int i = 0; i < length; i++)
+ processByte(buffer[i]);
+ }
+
+ private void processByte(byte byteToProcess) {
+ if (mUtf8ToFollow > 0) {
+ if ((byteToProcess & 0b11000000) == 0b10000000) {
+ // 10xxxxxx, a continuation byte.
+ mUtf8InputBuffer[mUtf8Index++] = byteToProcess;
+ if (--mUtf8ToFollow == 0) {
+ byte firstByteMask = (byte) (mUtf8Index == 2 ? 0b00011111 : (mUtf8Index == 3 ? 0b00001111 : 0b00000111));
+ int codePoint = (mUtf8InputBuffer[0] & firstByteMask);
+ for (int i = 1; i < mUtf8Index; i++)
+ codePoint = ((codePoint << 6) | (mUtf8InputBuffer[i] & 0b00111111));
+ if (((codePoint <= 0b1111111) && mUtf8Index > 1) || (codePoint < 0b11111111111 && mUtf8Index > 2)
+ || (codePoint < 0b1111111111111111 && mUtf8Index > 3)) {
+ // Overlong encoding.
+ codePoint = UNICODE_REPLACEMENT_CHAR;
+ }
+
+ mUtf8Index = mUtf8ToFollow = 0;
+
+ if (codePoint >= 0x80 && codePoint <= 0x9F) {
+ // Sequence decoded to a C1 control character which we ignore. They are
+ // not used nowadays and increases the risk of messing up the terminal state
+ // on binary input. XTerm does not allow them in utf-8:
+ // "It is not possible to use a C1 control obtained from decoding the
+ // UTF-8 text" - http://invisible-island.net/xterm/ctlseqs/ctlseqs.html
+ } else {
+ switch (Character.getType(codePoint)) {
+ case Character.UNASSIGNED:
+ case Character.SURROGATE:
+ codePoint = UNICODE_REPLACEMENT_CHAR;
+ }
+ processCodePoint(codePoint);
+ }
+ }
+ } else {
+ // Not a UTF-8 continuation byte so replace the entire sequence up to now with the replacement char:
+ mUtf8Index = mUtf8ToFollow = 0;
+ emitCodePoint(UNICODE_REPLACEMENT_CHAR);
+ // The Unicode Standard Version 6.2 – Core Specification
+ // (http://www.unicode.org/versions/Unicode6.2.0/ch03.pdf):
+ // "If the converter encounters an ill-formed UTF-8 code unit sequence which starts with a valid first
+ // byte, but which does not continue with valid successor bytes (see Table 3-7), it must not consume the
+ // successor bytes as part of the ill-formed subsequence
+ // whenever those successor bytes themselves constitute part of a well-formed UTF-8 code unit
+ // subsequence."
+ processByte(byteToProcess);
+ }
+ } else {
+ if ((byteToProcess & 0b10000000) == 0) { // The leading bit is not set so it is a 7-bit ASCII character.
+ processCodePoint(byteToProcess);
+ return;
+ } else if ((byteToProcess & 0b11100000) == 0b11000000) { // 110xxxxx, a two-byte sequence.
+ mUtf8ToFollow = 1;
+ } else if ((byteToProcess & 0b11110000) == 0b11100000) { // 1110xxxx, a three-byte sequence.
+ mUtf8ToFollow = 2;
+ } else if ((byteToProcess & 0b11111000) == 0b11110000) { // 11110xxx, a four-byte sequence.
+ mUtf8ToFollow = 3;
+ } else {
+ // Not a valid UTF-8 sequence start, signal invalid data:
+ processCodePoint(UNICODE_REPLACEMENT_CHAR);
+ return;
+ }
+ mUtf8InputBuffer[mUtf8Index++] = byteToProcess;
+ }
+ }
+
+ public void processCodePoint(int b) {
+ // The Application Program-Control (APC) string might be arbitrary non-printable characters, so handle that early.
+ if (mEscapeState == ESC_APC) {
+ doApc(b);
+ return;
+ } else if (mEscapeState == ESC_APC_ESCAPE) {
+ doApcEscape(b);
+ return;
+ }
+
+ switch (b) {
+ case 0: // Null character (NUL, ^@). Do nothing.
+ break;
+ case 7: // Bell (BEL, ^G, \a). If in an OSC sequence, BEL may terminate a string; otherwise signal bell.
+ if (mEscapeState == ESC_OSC)
+ doOsc(b);
+ else
+ mSession.onBell();
+ break;
+ case 8: // Backspace (BS, ^H).
+ if (mLeftMargin == mCursorCol) {
+ // Jump to previous line if it was auto-wrapped.
+ int previousRow = mCursorRow - 1;
+ if (previousRow >= 0 && mScreen.getLineWrap(previousRow)) {
+ mScreen.clearLineWrap(previousRow);
+ setCursorRowCol(previousRow, mRightMargin - 1);
+ }
+ } else {
+ setCursorCol(mCursorCol - 1);
+ }
+ break;
+ case 9: // Horizontal tab (HT, \t) - move to next tab stop, but not past edge of screen
+ // XXX: Should perhaps use color if writing to new cells. Try with
+ // printf "\033[41m\tXX\033[0m\n"
+ // The OSX Terminal.app colors the spaces from the tab red, but xterm does not.
+ // Note that Terminal.app only colors on new cells, in e.g.
+ // printf "\033[41m\t\r\033[42m\tXX\033[0m\n"
+ // the first cells are created with a red background, but when tabbing over
+ // them again with a green background they are not overwritten.
+ mCursorCol = nextTabStop(1);
+ break;
+ case 10: // Line feed (LF, \n).
+ case 11: // Vertical tab (VT, \v).
+ case 12: // Form feed (FF, \f).
+ doLinefeed();
+ break;
+ case 13: // Carriage return (CR, \r).
+ setCursorCol(mLeftMargin);
+ break;
+ case 14: // Shift Out (Ctrl-N, SO) → Switch to Alternate Character Set. This invokes the G1 character set.
+ mUseLineDrawingUsesG0 = false;
+ break;
+ case 15: // Shift In (Ctrl-O, SI) → Switch to Standard Character Set. This invokes the G0 character set.
+ mUseLineDrawingUsesG0 = true;
+ break;
+ case 24: // CAN.
+ case 26: // SUB.
+ if (mEscapeState != ESC_NONE) {
+ // FIXME: What is this??
+ mEscapeState = ESC_NONE;
+ emitCodePoint(127);
+ }
+ break;
+ case 27: // ESC
+ // Starts an escape sequence unless we're parsing a string
+ if (mEscapeState == ESC_P) {
+ // XXX: Ignore escape when reading device control sequence, since it may be part of string terminator.
+ return;
+ } else if (mEscapeState != ESC_OSC) {
+ startEscapeSequence();
+ } else {
+ doOsc(b);
+ }
+ break;
+ default:
+ mContinueSequence = false;
+ switch (mEscapeState) {
+ case ESC_NONE:
+ if (b >= 32) emitCodePoint(b);
+ break;
+ case ESC:
+ doEsc(b);
+ break;
+ case ESC_POUND:
+ doEscPound(b);
+ break;
+ case ESC_SELECT_LEFT_PAREN: // Designate G0 Character Set (ISO 2022, VT100).
+ mUseLineDrawingG0 = (b == '0');
+ break;
+ case ESC_SELECT_RIGHT_PAREN: // Designate G1 Character Set (ISO 2022, VT100).
+ mUseLineDrawingG1 = (b == '0');
+ break;
+ case ESC_CSI:
+ doCsi(b);
+ break;
+ case ESC_CSI_UNSUPPORTED_PARAMETER_BYTE:
+ case ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE:
+ doCsiUnsupportedParameterOrIntermediateByte(b);
+ break;
+ case ESC_CSI_EXCLAMATION:
+ if (b == 'p') { // Soft terminal reset (DECSTR, http://vt100.net/docs/vt510-rm/DECSTR).
+ reset();
+ } else {
+ unknownSequence(b);
+ }
+ break;
+ case ESC_CSI_QUESTIONMARK:
+ doCsiQuestionMark(b);
+ break;
+ case ESC_CSI_BIGGERTHAN:
+ doCsiBiggerThan(b);
+ break;
+ case ESC_CSI_DOLLAR:
+ boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE);
+ int effectiveTopMargin = originMode ? mTopMargin : 0;
+ int effectiveBottomMargin = originMode ? mBottomMargin : mRows;
+ int effectiveLeftMargin = originMode ? mLeftMargin : 0;
+ int effectiveRightMargin = originMode ? mRightMargin : mColumns;
+ switch (b) {
+ case 'v': // ${CSI}${SRC_TOP}${SRC_LEFT}${SRC_BOTTOM}${SRC_RIGHT}${SRC_PAGE}${DST_TOP}${DST_LEFT}${DST_PAGE}$v"
+ // Copy rectangular area (DECCRA - http://vt100.net/docs/vt510-rm/DECCRA):
+ // "If Pbs is greater than Pts, or Pls is greater than Prs, the terminal ignores DECCRA.
+ // The coordinates of the rectangular area are affected by the setting of origin mode (DECOM).
+ // DECCRA is not affected by the page margins.
+ // The copied text takes on the line attributes of the destination area.
+ // If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, then the value
+ // is treated as the width or height of that page.
+ // If the destination area is partially off the page, then DECCRA clips the off-page data.
+ // DECCRA does not change the active cursor position."
+ int topSource = Math.min(getArg(0, 1, true) - 1 + effectiveTopMargin, mRows);
+ int leftSource = Math.min(getArg(1, 1, true) - 1 + effectiveLeftMargin, mColumns);
+ // Inclusive, so do not subtract one:
+ int bottomSource = Math.min(Math.max(getArg(2, mRows, true) + effectiveTopMargin, topSource), mRows);
+ int rightSource = Math.min(Math.max(getArg(3, mColumns, true) + effectiveLeftMargin, leftSource), mColumns);
+ // int sourcePage = getArg(4, 1, true);
+ int destionationTop = Math.min(getArg(5, 1, true) - 1 + effectiveTopMargin, mRows);
+ int destinationLeft = Math.min(getArg(6, 1, true) - 1 + effectiveLeftMargin, mColumns);
+ // int destinationPage = getArg(7, 1, true);
+ int heightToCopy = Math.min(mRows - destionationTop, bottomSource - topSource);
+ int widthToCopy = Math.min(mColumns - destinationLeft, rightSource - leftSource);
+ mScreen.blockCopy(leftSource, topSource, widthToCopy, heightToCopy, destinationLeft, destionationTop);
+ break;
+ case '{': // ${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${"
+ // Selective erase rectangular area (DECSERA - http://www.vt100.net/docs/vt510-rm/DECSERA).
+ case 'x': // ${CSI}${CHAR};${TOP}${LEFT}${BOTTOM}${RIGHT}$x"
+ // Fill rectangular area (DECFRA - http://www.vt100.net/docs/vt510-rm/DECFRA).
+ case 'z': // ${CSI}$${TOP}${LEFT}${BOTTOM}${RIGHT}$z"
+ // Erase rectangular area (DECERA - http://www.vt100.net/docs/vt510-rm/DECERA).
+ boolean erase = b != 'x';
+ boolean selective = b == '{';
+ // Only DECSERA keeps visual attributes, DECERA does not:
+ boolean keepVisualAttributes = erase && selective;
+ int argIndex = 0;
+ int fillChar = erase ? ' ' : getArg(argIndex++, -1, true);
+ // "Pch can be any value from 32 to 126 or from 160 to 255. If Pch is not in this range, then the
+ // terminal ignores the DECFRA command":
+ if ((fillChar >= 32 && fillChar <= 126) || (fillChar >= 160 && fillChar <= 255)) {
+ // "If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, the value
+ // is treated as the width or height of that page."
+ int top = Math.min(getArg(argIndex++, 1, true) + effectiveTopMargin, effectiveBottomMargin + 1);
+ int left = Math.min(getArg(argIndex++, 1, true) + effectiveLeftMargin, effectiveRightMargin + 1);
+ int bottom = Math.min(getArg(argIndex++, mRows, true) + effectiveTopMargin, effectiveBottomMargin);
+ int right = Math.min(getArg(argIndex, mColumns, true) + effectiveLeftMargin, effectiveRightMargin);
+ long style = getStyle();
+ for (int row = top - 1; row < bottom; row++)
+ for (int col = left - 1; col < right; col++)
+ if (!selective || (TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0)
+ mScreen.setChar(col, row, fillChar, keepVisualAttributes ? mScreen.getStyleAt(row, col) : style);
+ }
+ break;
+ case 'r': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$r"
+ // Change attributes in rectangular area (DECCARA - http://vt100.net/docs/vt510-rm/DECCARA).
+ case 't': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$t"
+ // Reverse attributes in rectangular area (DECRARA - http://www.vt100.net/docs/vt510-rm/DECRARA).
+ boolean reverse = b == 't';
+ // FIXME: "coordinates of the rectangular area are affected by the setting of origin mode (DECOM)".
+ int top = Math.min(getArg(0, 1, true) - 1, effectiveBottomMargin) + effectiveTopMargin;
+ int left = Math.min(getArg(1, 1, true) - 1, effectiveRightMargin) + effectiveLeftMargin;
+ int bottom = Math.min(getArg(2, mRows, true) + 1, effectiveBottomMargin - 1) + effectiveTopMargin;
+ int right = Math.min(getArg(3, mColumns, true) + 1, effectiveRightMargin - 1) + effectiveLeftMargin;
+ if (mArgIndex >= 4) {
+ if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1;
+ for (int i = 4; i <= mArgIndex; i++) {
+ int bits = 0;
+ boolean setOrClear = true; // True if setting, false if clearing.
+ switch (getArg(i, 0, false)) {
+ case 0: // Attributes off (no bold, no underline, no blink, positive image).
+ bits = (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE | TextStyle.CHARACTER_ATTRIBUTE_BLINK
+ | TextStyle.CHARACTER_ATTRIBUTE_INVERSE);
+ if (!reverse) setOrClear = false;
+ break;
+ case 1: // Bold.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD;
+ break;
+ case 4: // Underline.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ break;
+ case 5: // Blink.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK;
+ break;
+ case 7: // Negative image.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE;
+ break;
+ case 22: // No bold.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD;
+ setOrClear = false;
+ break;
+ case 24: // No underline.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ setOrClear = false;
+ break;
+ case 25: // No blink.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK;
+ setOrClear = false;
+ break;
+ case 27: // Positive image.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE;
+ setOrClear = false;
+ break;
+ }
+ if (reverse && !setOrClear) {
+ // Reverse attributes in rectangular area ignores non-(1,4,5,7) bits.
+ } else {
+ mScreen.setOrClearEffect(bits, setOrClear, reverse, isDecsetInternalBitSet(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE),
+ effectiveLeftMargin, effectiveRightMargin, top, left, bottom, right);
+ }
+ }
+ } else {
+ // Do nothing.
+ }
+ break;
+ default:
+ unknownSequence(b);
+ }
+ break;
+ case ESC_CSI_DOUBLE_QUOTE:
+ if (b == 'q') {
+ // http://www.vt100.net/docs/vt510-rm/DECSCA
+ int arg = getArg0(0);
+ if (arg == 0 || arg == 2) {
+ // DECSED and DECSEL can erase characters.
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_PROTECTED;
+ } else if (arg == 1) {
+ // DECSED and DECSEL cannot erase characters.
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_PROTECTED;
+ } else {
+ unknownSequence(b);
+ }
+ } else {
+ unknownSequence(b);
+ }
+ break;
+ case ESC_CSI_SINGLE_QUOTE:
+ if (b == '}') { // Insert Ps Column(s) (default = 1) (DECIC), VT420 and up.
+ int columnsAfterCursor = mRightMargin - mCursorCol;
+ int columnsToInsert = Math.min(getArg0(1), columnsAfterCursor);
+ int columnsToMove = columnsAfterCursor - columnsToInsert;
+ mScreen.blockCopy(mCursorCol, 0, columnsToMove, mRows, mCursorCol + columnsToInsert, 0);
+ blockClear(mCursorCol, 0, columnsToInsert, mRows);
+ } else if (b == '~') { // Delete Ps Column(s) (default = 1) (DECDC), VT420 and up.
+ int columnsAfterCursor = mRightMargin - mCursorCol;
+ int columnsToDelete = Math.min(getArg0(1), columnsAfterCursor);
+ int columnsToMove = columnsAfterCursor - columnsToDelete;
+ mScreen.blockCopy(mCursorCol + columnsToDelete, 0, columnsToMove, mRows, mCursorCol, 0);
+ } else {
+ unknownSequence(b);
+ }
+ break;
+ case ESC_PERCENT:
+ break;
+ case ESC_OSC:
+ doOsc(b);
+ break;
+ case ESC_OSC_ESC:
+ doOscEsc(b);
+ break;
+ case ESC_P:
+ doDeviceControl(b);
+ break;
+ case ESC_CSI_QUESTIONMARK_ARG_DOLLAR:
+ if (b == 'p') {
+ // Request DEC private mode (DECRQM).
+ int mode = getArg0(0);
+ int value;
+ if (mode == 47 || mode == 1047 || mode == 1049) {
+ // This state is carried by mScreen pointer.
+ value = (mScreen == mAltBuffer) ? 1 : 2;
+ } else {
+ int internalBit = mapDecSetBitToInternalBit(mode);
+ if (internalBit != -1) {
+ value = isDecsetInternalBitSet(internalBit) ? 1 : 2; // 1=set, 2=reset.
+ } else {
+ Logger.logError(mClient, LOG_TAG, "Got DECRQM for unrecognized private DEC mode=" + mode);
+ value = 0; // 0=not recognized, 3=permanently set, 4=permanently reset
+ }
+ }
+ mSession.write(String.format(Locale.US, "\033[?%d;%d$y", mode, value));
+ } else {
+ unknownSequence(b);
+ }
+ break;
+ case ESC_CSI_ARGS_SPACE:
+ int arg = getArg0(0);
+ switch (b) {
+ case 'q': // "${CSI}${STYLE} q" - set cursor style (http://www.vt100.net/docs/vt510-rm/DECSCUSR).
+ switch (arg) {
+ case 0: // Blinking block.
+ case 1: // Blinking block.
+ case 2: // Steady block.
+ mCursorStyle = TERMINAL_CURSOR_STYLE_BLOCK;
+ break;
+ case 3: // Blinking underline.
+ case 4: // Steady underline.
+ mCursorStyle = TERMINAL_CURSOR_STYLE_UNDERLINE;
+ break;
+ case 5: // Blinking bar (xterm addition).
+ case 6: // Steady bar (xterm addition).
+ mCursorStyle = TERMINAL_CURSOR_STYLE_BAR;
+ break;
+ }
+ break;
+ case 't':
+ case 'u':
+ // Set margin-bell volume - ignore.
+ break;
+ default:
+ unknownSequence(b);
+ }
+ break;
+ case ESC_CSI_ARGS_ASTERIX:
+ int attributeChangeExtent = getArg0(0);
+ if (b == 'x' && (attributeChangeExtent >= 0 && attributeChangeExtent <= 2)) {
+ // Select attribute change extent (DECSACE - http://www.vt100.net/docs/vt510-rm/DECSACE).
+ setDecsetinternalBit(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE, attributeChangeExtent == 2);
+ } else {
+ unknownSequence(b);
+ }
+ break;
+ default:
+ unknownSequence(b);
+ break;
+ }
+ if (!mContinueSequence) mEscapeState = ESC_NONE;
+ break;
+ }
+ }
+
+ /** When in {@link #ESC_P} ("device control") sequence. */
+ private void doDeviceControl(int b) {
+ switch (b) {
+ case (byte) '\\': // End of ESC \ string Terminator
+ {
+ String dcs = mOSCOrDeviceControlArgs.toString();
+ // DCS $ q P t ST. Request Status String (DECRQSS)
+ if (dcs.startsWith("$q")) {
+ if (dcs.equals("$q\"p")) {
+ // DECSCL, conformance level, http://www.vt100.net/docs/vt510-rm/DECSCL:
+ String csiString = "64;1\"p";
+ mSession.write("\033P1$r" + csiString + "\033\\");
+ } else {
+ finishSequenceAndLogError("Unrecognized DECRQSS string: '" + dcs + "'");
+ }
+ } else if (dcs.startsWith("+q")) {
+ // Request Termcap/Terminfo String. The string following the "q" is a list of names encoded in
+ // hexadecimal (2 digits per character) separated by ; which correspond to termcap or terminfo key
+ // names.
+ // Two special features are also recognized, which are not key names: Co for termcap colors (or colors
+ // for terminfo colors), and TN for termcap name (or name for terminfo name).
+ // xterm responds with DCS 1 + r P t ST for valid requests, adding to P t an = , and the value of the
+ // corresponding string that xterm would send, or DCS 0 + r P t ST for invalid requests. The strings are
+ // encoded in hexadecimal (2 digits per character).
+ // Example:
+ // :kr=\EOC: ks=\E[?1h\E=: ku=\EOA: le=^H:mb=\E[5m:md=\E[1m:\
+ // where
+ // kd=down-arrow key
+ // kl=left-arrow key
+ // kr=right-arrow key
+ // ku=up-arrow key
+ // #2=key_shome, "shifted home"
+ // #4=key_sleft, "shift arrow left"
+ // %i=key_sright, "shift arrow right"
+ // *7=key_send, "shifted end"
+ // k1=F1 function key
+
+ // Example: Request for ku is "ESC P + q 6 b 7 5 ESC \", where 6b7d=ku in hexadecimal.
+ // Xterm response in normal cursor mode:
+ // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x5B 0x41 = 27 91 65 = ESC [ A
+ // Xterm response in application cursor mode:
+ // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x4F 0x41 = 27 91 65 = ESC 0 A
+
+ // #4 is "shift arrow left":
+ // *** Device Control (DCS) for '#4'- 'ESC P + q 23 34 ESC \'
+ // Response: <27> P 1 + r 2 3 3 4 = 1 B 5 B 3 1 3 B 3 2 4 4 <27> \
+ // where 0x1B 0x5B 0x31 0x3B 0x32 0x44 = ESC [ 1 ; 2 D
+ // which we find in: TermKeyListener.java: KEY_MAP.put(KEYMOD_SHIFT | KEYCODE_DPAD_LEFT, "\033[1;2D");
+
+ // See http://h30097.www3.hp.com/docs/base_doc/DOCUMENTATION/V40G_HTML/MAN/MAN4/0178____.HTM for what to
+ // respond, as well as http://www.freebsd.org/cgi/man.cgi?query=termcap&sektion=5#CAPABILITIES for
+ // the meaning of e.g. "ku", "kd", "kr", "kl"
+
+ for (String part : dcs.substring(2).split(";")) {
+ if (part.length() % 2 == 0) {
+ StringBuilder transBuffer = new StringBuilder();
+ char c;
+ for (int i = 0; i < part.length(); i += 2) {
+ try {
+ c = (char) Long.decode("0x" + part.charAt(i) + "" + part.charAt(i + 1)).longValue();
+ } catch (NumberFormatException e) {
+ Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Invalid device termcap/terminfo encoded name \"" + part + "\"", e);
+ continue;
+ }
+ transBuffer.append(c);
+ }
+
+ String trans = transBuffer.toString();
+ String responseValue;
+ switch (trans) {
+ case "Co":
+ case "colors":
+ responseValue = "256"; // Number of colors.
+ break;
+ case "TN":
+ case "name":
+ responseValue = "xterm";
+ break;
+ default:
+ responseValue = KeyHandler.getCodeFromTermcap(trans, isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS),
+ isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD));
+ break;
+ }
+ if (responseValue == null) {
+ switch (trans) {
+ case "%1": // Help key - ignore
+ case "&8": // Undo key - ignore.
+ break;
+ default:
+ Logger.logWarn(mClient, LOG_TAG, "Unhandled termcap/terminfo name: '" + trans + "'");
+ }
+ // Respond with invalid request:
+ mSession.write("\033P0+r" + part + "\033\\");
+ } else {
+ StringBuilder hexEncoded = new StringBuilder();
+ for (int j = 0; j < responseValue.length(); j++) {
+ hexEncoded.append(String.format("%02X", (int) responseValue.charAt(j)));
+ }
+ mSession.write("\033P1+r" + part + "=" + hexEncoded + "\033\\");
+ }
+ } else {
+ Logger.logError(mClient, LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part);
+ }
+ }
+ } else {
+ if (LOG_ESCAPE_SEQUENCES)
+ Logger.logError(mClient, LOG_TAG, "Unrecognized device control string: " + dcs);
+ }
+ finishSequence();
+ }
+ break;
+ default:
+ if (mOSCOrDeviceControlArgs.length() > MAX_OSC_STRING_LENGTH) {
+ // Too long.
+ mOSCOrDeviceControlArgs.setLength(0);
+ finishSequence();
+ } else {
+ mOSCOrDeviceControlArgs.appendCodePoint(b);
+ continueSequence(mEscapeState);
+ }
+ }
+ }
+
+ /**
+ * When in {@link #ESC_APC} (APC, Application Program Command) sequence.
+ */
+ private void doApc(int b) {
+ if (b == 27) {
+ continueSequence(ESC_APC_ESCAPE);
+ }
+ // Eat APC sequences silently for now.
+ }
+
+ /**
+ * When in {@link #ESC_APC} (APC, Application Program Command) sequence.
+ */
+ private void doApcEscape(int b) {
+ if (b == '\\') {
+ // A String Terminator (ST), ending the APC escape sequence.
+ finishSequence();
+ } else {
+ // The Escape character was not the start of a String Terminator (ST),
+ // but instead just data inside of the APC escape sequence.
+ continueSequence(ESC_APC);
+ }
+ }
+
+ private int nextTabStop(int numTabs) {
+ for (int i = mCursorCol + 1; i < mColumns; i++)
+ if (mTabStop[i] && --numTabs == 0) return Math.min(i, mRightMargin);
+ return mRightMargin - 1;
+ }
+
+ /**
+ * Process byte while in the {@link #ESC_CSI_UNSUPPORTED_PARAMETER_BYTE} or
+ * {@link #ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE} escape state.
+ *
+ * Parse unsupported parameter, intermediate and final bytes but ignore them.
+ *
+ * > For Control Sequence Introducer, ... the ESC [ is followed by
+ * > - any number (including none) of "parameter bytes" in the range 0x30–0x3F (ASCII 0–9:;<=>?),
+ * > - then by any number of "intermediate bytes" in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./),
+ * > - then finally by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~).
+ *
+ * - https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands
+ * - https://invisible-island.net/xterm/ecma-48-parameter-format.html#section5.4
+ */
+ private void doCsiUnsupportedParameterOrIntermediateByte(int b) {
+ if (mEscapeState == ESC_CSI_UNSUPPORTED_PARAMETER_BYTE && b >= 0x30 && b <= 0x3F) {
+ // Supported `0–9:;>?` or unsupported `<=` parameter byte after an
+ // initial unsupported parameter byte in `doCsi()`, or a sequential parameter byte.
+ continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE);
+ } else if (b >= 0x20 && b <= 0x2F) {
+ // Optional intermediate byte `!"#$%&'()*+,-./` after parameter or intermediate byte.
+ continueSequence(ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE);
+ } else if (b >= 0x40 && b <= 0x7E) {
+ // Final byte `@A–Z[\]^_`a–z{|}~` after parameter or intermediate byte.
+ // Calling `unknownSequence()` would log an error with only a final byte, so ignore it for now.
+ finishSequence();
+ } else {
+ unknownSequence(b);
+ }
+ }
+
+ /** Process byte while in the {@link #ESC_CSI_QUESTIONMARK} escape state. */
+ private void doCsiQuestionMark(int b) {
+ switch (b) {
+ case 'J': // Selective erase in display (DECSED) - http://www.vt100.net/docs/vt510-rm/DECSED.
+ case 'K': // Selective erase in line (DECSEL) - http://vt100.net/docs/vt510-rm/DECSEL.
+ mAboutToAutoWrap = false;
+ int fillChar = ' ';
+ int startCol = -1;
+ int startRow = -1;
+ int endCol = -1;
+ int endRow = -1;
+ boolean justRow = (b == 'K');
+ switch (getArg0(0)) {
+ case 0: // Erase from the active position to the end, inclusive (default).
+ startCol = mCursorCol;
+ startRow = mCursorRow;
+ endCol = mColumns;
+ endRow = justRow ? (mCursorRow + 1) : mRows;
+ break;
+ case 1: // Erase from start to the active position, inclusive.
+ startCol = 0;
+ startRow = justRow ? mCursorRow : 0;
+ endCol = mCursorCol + 1;
+ endRow = mCursorRow + 1;
+ break;
+ case 2: // Erase all of the display/line.
+ startCol = 0;
+ startRow = justRow ? mCursorRow : 0;
+ endCol = mColumns;
+ endRow = justRow ? (mCursorRow + 1) : mRows;
+ break;
+ default:
+ unknownSequence(b);
+ break;
+ }
+ long style = getStyle();
+ for (int row = startRow; row < endRow; row++) {
+ for (int col = startCol; col < endCol; col++) {
+ if ((TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0)
+ mScreen.setChar(col, row, fillChar, style);
+ }
+ }
+ break;
+ case 'h':
+ case 'l':
+ if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1;
+ for (int i = 0; i <= mArgIndex; i++)
+ doDecSetOrReset(b == 'h', mArgs[i]);
+ break;
+ case 'n': // Device Status Report (DSR, DEC-specific).
+ switch (getArg0(-1)) {
+ case 6:
+ // Extended Cursor Position (DECXCPR - http://www.vt100.net/docs/vt510-rm/DECXCPR). Page=1.
+ mSession.write(String.format(Locale.US, "\033[?%d;%d;1R", mCursorRow + 1, mCursorCol + 1));
+ break;
+ default:
+ finishSequence();
+ return;
+ }
+ break;
+ case 'r':
+ case 's':
+ if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1;
+ for (int i = 0; i <= mArgIndex; i++) {
+ int externalBit = mArgs[i];
+ int internalBit = mapDecSetBitToInternalBit(externalBit);
+ if (internalBit == -1) {
+ Logger.logWarn(mClient, LOG_TAG, "Ignoring request to save/recall decset bit=" + externalBit);
+ } else {
+ if (b == 's') {
+ mSavedDecSetFlags |= internalBit;
+ } else {
+ doDecSetOrReset((mSavedDecSetFlags & internalBit) != 0, externalBit);
+ }
+ }
+ }
+ break;
+ case '$':
+ continueSequence(ESC_CSI_QUESTIONMARK_ARG_DOLLAR);
+ return;
+ default:
+ parseArg(b);
+ }
+ }
+
+ public void doDecSetOrReset(boolean setting, int externalBit) {
+ int internalBit = mapDecSetBitToInternalBit(externalBit);
+ if (internalBit != -1) {
+ setDecsetinternalBit(internalBit, setting);
+ }
+ switch (externalBit) {
+ case 1: // Application Cursor Keys (DECCKM).
+ break;
+ case 3: // Set: 132 column mode (. Reset: 80 column mode. ANSI name: DECCOLM.
+ // We don't actually set/reset 132 cols, but we do want the side effects
+ // (FIXME: Should only do this if the 95 DECSET bit (DECNCSM) is set, and if changing value?):
+ // Sets the left, right, top and bottom scrolling margins to their default positions, which is important for
+ // the "reset" utility to really reset the terminal:
+ mLeftMargin = mTopMargin = 0;
+ mBottomMargin = mRows;
+ mRightMargin = mColumns;
+ // "DECCOLM resets vertical split screen mode (DECLRMM) to unavailable":
+ setDecsetinternalBit(DECSET_BIT_LEFTRIGHT_MARGIN_MODE, false);
+ // "Erases all data in page memory":
+ blockClear(0, 0, mColumns, mRows);
+ setCursorRowCol(0, 0);
+ break;
+ case 4: // DECSCLM-Scrolling Mode. Ignore.
+ break;
+ case 5: // Reverse video. No action.
+ break;
+ case 6: // Set: Origin Mode. Reset: Normal Cursor Mode. Ansi name: DECOM.
+ if (setting) setCursorPosition(0, 0);
+ break;
+ case 7: // Wrap-around bit, not specific action.
+ case 8: // Auto-repeat Keys (DECARM). Do not implement.
+ case 9: // X10 mouse reporting - outdated. Do not implement.
+ case 12: // Control cursor blinking - ignore.
+ case 25: // Hide/show cursor - no action needed, renderer will check with shouldCursorBeVisible().
+ if (mClient != null)
+ mClient.onTerminalCursorStateChange(setting);
+ break;
+ case 40: // Allow 80 => 132 Mode, ignore.
+ case 45: // TODO: Reverse wrap-around. Implement???
+ case 66: // Application keypad (DECNKM).
+ break;
+ case 69: // Left and right margin mode (DECLRMM).
+ if (!setting) {
+ mLeftMargin = 0;
+ mRightMargin = mColumns;
+ }
+ break;
+ case 1000:
+ case 1001:
+ case 1002:
+ case 1003:
+ case 1004:
+ case 1005: // UTF-8 mouse mode, ignore.
+ case 1006: // SGR Mouse Mode
+ case 1015:
+ case 1034: // Interpret "meta" key, sets eighth bit.
+ break;
+ case 1048: // Set: Save cursor as in DECSC. Reset: Restore cursor as in DECRC.
+ if (setting)
+ saveCursor();
+ else
+ restoreCursor();
+ break;
+ case 47:
+ case 1047:
+ case 1049: {
+ // Set: Save cursor as in DECSC and use Alternate Screen Buffer, clearing it first.
+ // Reset: Use Normal Screen Buffer and restore cursor as in DECRC.
+ TerminalBuffer newScreen = setting ? mAltBuffer : mMainBuffer;
+ if (newScreen != mScreen) {
+ boolean resized = !(newScreen.mColumns == mColumns && newScreen.mScreenRows == mRows);
+ if (setting) saveCursor();
+ mScreen = newScreen;
+ if (!setting) {
+ int col = mSavedStateMain.mSavedCursorCol;
+ int row = mSavedStateMain.mSavedCursorRow;
+ restoreCursor();
+ if (resized) {
+ // Restore cursor position _not_ clipped to current screen (let resizeScreen() handle that):
+ mCursorCol = col;
+ mCursorRow = row;
+ }
+ }
+ // Check if buffer size needs to be updated:
+ if (resized) resizeScreen();
+ // Clear new screen if alt buffer:
+ if (newScreen == mAltBuffer)
+ newScreen.blockSet(0, 0, mColumns, mRows, ' ', getStyle());
+ }
+ break;
+ }
+ case 2004:
+ // Bracketed paste mode - setting bit is enough.
+ break;
+ default:
+ unknownParameter(externalBit);
+ break;
+ }
+ }
+
+ private void doCsiBiggerThan(int b) {
+ switch (b) {
+ case 'c': // "${CSI}>c" or "${CSI}>c". Secondary Device Attributes (DA2).
+ // Originally this was used for the terminal to respond with "identification code, firmware version level,
+ // and hardware options" (http://vt100.net/docs/vt510-rm/DA2), with the first "41" meaning the VT420
+ // terminal type. This is not used anymore, but the second version level field has been changed by xterm
+ // to mean it's release number ("patch numbers" listed at http://invisible-island.net/xterm/xterm.log.html),
+ // and some applications use it as a feature check:
+ // * tmux used to have a "xterm won't reach version 500 for a while so set that as the upper limit" check,
+ // and then check "xterm_version > 270" if rectangular area operations such as DECCRA could be used.
+ // * vim checks xterm version number >140 for "Request termcap/terminfo string" functionality >276 for SGR
+ // mouse report.
+ // The third number is a keyboard identifier not used nowadays.
+ mSession.write("\033[>41;320;0c");
+ break;
+ case 'm':
+ // https://bugs.launchpad.net/gnome-terminal/+bug/96676/comments/25
+ // Depending on the first number parameter, this can set one of the xterm resources
+ // modifyKeyboard, modifyCursorKeys, modifyFunctionKeys and modifyOtherKeys.
+ // http://invisible-island.net/xterm/manpage/xterm.html#RESOURCES
+
+ // * modifyKeyboard (parameter=1):
+ // Normally xterm makes a special case regarding modifiers (shift, control, etc.) to handle special keyboard
+ // layouts (legacy and vt220). This is done to provide compatible keyboards for DEC VT220 and related
+ // terminals that implement user-defined keys (UDK).
+ // The bits of the resource value selectively enable modification of the given category when these keyboards
+ // are selected. The default is "0":
+ // (0) The legacy/vt220 keyboards interpret only the Control-modifier when constructing numbered
+ // function-keys. Other special keys are not modified.
+ // (1) allows modification of the numeric keypad
+ // (2) allows modification of the editing keypad
+ // (4) allows modification of function-keys, overrides use of Shift-modifier for UDK.
+ // (8) allows modification of other special keys
+
+ // * modifyCursorKeys (parameter=2):
+ // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a
+ // parameter to the escape sequence returned by a cursor-key. The default is "2".
+ // - Set it to -1 to disable it.
+ // - Set it to 0 to use the old/obsolete behavior.
+ // - Set it to 1 to prefix modified sequences with CSI.
+ // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first.
+ // - Set it to 3 to mark the sequence with a ">" to hint that it is private.
+
+ // * modifyFunctionKeys (parameter=3):
+ // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a
+ // parameter to the escape sequence returned by a (numbered) function-
+ // key. The default is "2". The resource values are similar to modifyCursorKeys:
+ // Set it to -1 to permit the user to use shift- and control-modifiers to construct function-key strings
+ // using the normal encoding scheme.
+ // - Set it to 0 to use the old/obsolete behavior.
+ // - Set it to 1 to prefix modified sequences with CSI.
+ // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first.
+ // - Set it to 3 to mark the sequence with a ">" to hint that it is private.
+ // If modifyFunctionKeys is zero, xterm uses Control- and Shift-modifiers to allow the user to construct
+ // numbered function-keys beyond the set provided by the keyboard:
+ // (Control) adds the value given by the ctrlFKeys resource.
+ // (Shift) adds twice the value given by the ctrlFKeys resource.
+ // (Control/Shift) adds three times the value given by the ctrlFKeys resource.
+ //
+ // As a special case, legacy (when oldFunctionKeys is true) or vt220 (when sunKeyboard is true)
+ // keyboards interpret only the Control-modifier when constructing numbered function-keys.
+ // This is done to provide compatible keyboards for DEC VT220 and related terminals that
+ // implement user-defined keys (UDK).
+
+ // * modifyOtherKeys (parameter=4):
+ // Like modifyCursorKeys, tells xterm to construct an escape sequence for other keys (such as "2") when
+ // modified by Control-, Alt- or Meta-modifiers. This feature does not apply to function keys and
+ // well-defined keys such as ESC or the control keys. The default is "0".
+ // (0) disables this feature.
+ // (1) enables this feature for keys except for those with well-known behavior, e.g., Tab, Backarrow and
+ // some special control character cases, e.g., Control-Space to make a NUL.
+ // (2) enables this feature for keys including the exceptions listed.
+ Logger.logError(mClient, LOG_TAG, "(ignored) CSI > MODIFY RESOURCE: " + getArg0(-1) + " to " + getArg1(-1));
+ break;
+ default:
+ parseArg(b);
+ break;
+ }
+ }
+
+ private void startEscapeSequence() {
+ mEscapeState = ESC;
+ mArgIndex = 0;
+ Arrays.fill(mArgs, -1);
+ mArgsSubParamsBitSet = 0;
+ }
+
+ private void doLinefeed() {
+ boolean belowScrollingRegion = mCursorRow >= mBottomMargin;
+ int newCursorRow = mCursorRow + 1;
+ if (belowScrollingRegion) {
+ // Move down (but not scroll) as long as we are above the last row.
+ if (mCursorRow != mRows - 1) {
+ setCursorRow(newCursorRow);
+ }
+ } else {
+ if (newCursorRow == mBottomMargin) {
+ scrollDownOneLine();
+ newCursorRow = mBottomMargin - 1;
+ }
+ setCursorRow(newCursorRow);
+ }
+ }
+
+ private void continueSequence(int state) {
+ mEscapeState = state;
+ mContinueSequence = true;
+ }
+
+ private void doEscPound(int b) {
+ switch (b) {
+ case '8': // Esc # 8 - DEC screen alignment test - fill screen with E's.
+ mScreen.blockSet(0, 0, mColumns, mRows, 'E', getStyle());
+ break;
+ default:
+ unknownSequence(b);
+ break;
+ }
+ }
+
+ /** Encountering a character in the {@link #ESC} state. */
+ private void doEsc(int b) {
+ switch (b) {
+ case '#':
+ continueSequence(ESC_POUND);
+ break;
+ case '(':
+ continueSequence(ESC_SELECT_LEFT_PAREN);
+ break;
+ case ')':
+ continueSequence(ESC_SELECT_RIGHT_PAREN);
+ break;
+ case '6': // Back index (http://www.vt100.net/docs/vt510-rm/DECBI). Move left, insert blank column if start.
+ if (mCursorCol > mLeftMargin) {
+ mCursorCol--;
+ } else {
+ int rows = mBottomMargin - mTopMargin;
+ mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin + 1, mTopMargin);
+ mScreen.blockSet(mLeftMargin, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0));
+ }
+ break;
+ case '7': // DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC
+ saveCursor();
+ break;
+ case '8': // DECRC restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC
+ restoreCursor();
+ break;
+ case '9': // Forward Index (http://www.vt100.net/docs/vt510-rm/DECFI). Move right, insert blank column if end.
+ if (mCursorCol < mRightMargin - 1) {
+ mCursorCol++;
+ } else {
+ int rows = mBottomMargin - mTopMargin;
+ mScreen.blockCopy(mLeftMargin + 1, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin, mTopMargin);
+ mScreen.blockSet(mRightMargin - 1, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0));
+ }
+ break;
+ case 'c': // RIS - Reset to Initial State (http://vt100.net/docs/vt510-rm/RIS).
+ reset();
+ mMainBuffer.clearTranscript();
+ blockClear(0, 0, mColumns, mRows);
+ setCursorPosition(0, 0);
+ break;
+ case 'D': // INDEX
+ doLinefeed();
+ break;
+ case 'E': // Next line (http://www.vt100.net/docs/vt510-rm/NEL).
+ setCursorCol(isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE) ? mLeftMargin : 0);
+ doLinefeed();
+ break;
+ case 'F': // Cursor to lower-left corner of screen
+ setCursorRowCol(0, mBottomMargin - 1);
+ break;
+ case 'H': // Tab set
+ mTabStop[mCursorCol] = true;
+ break;
+ case 'M': // "${ESC}M" - reverse index (RI).
+ // http://www.vt100.net/docs/vt100-ug/chapter3.html: "Move the active position to the same horizontal
+ // position on the preceding line. If the active position is at the top margin, a scroll down is performed".
+ if (mCursorRow <= mTopMargin) {
+ mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, mBottomMargin - (mTopMargin + 1), mLeftMargin, mTopMargin + 1);
+ blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin);
+ } else {
+ mCursorRow--;
+ }
+ break;
+ case 'N': // SS2, ignore.
+ case '0': // SS3, ignore.
+ break;
+ case 'P': // Device control string
+ mOSCOrDeviceControlArgs.setLength(0);
+ continueSequence(ESC_P);
+ break;
+ case '[':
+ continueSequence(ESC_CSI);
+ break;
+ case '=': // DECKPAM
+ setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, true);
+ break;
+ case ']': // OSC
+ mOSCOrDeviceControlArgs.setLength(0);
+ continueSequence(ESC_OSC);
+ break;
+ case '>': // DECKPNM
+ setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false);
+ break;
+ case '_': // APC - Application Program Command.
+ continueSequence(ESC_APC);
+ break;
+ default:
+ unknownSequence(b);
+ break;
+ }
+ }
+
+ /** DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC . See {@link #restoreCursor()}. */
+ private void saveCursor() {
+ SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt;
+ state.mSavedCursorRow = mCursorRow;
+ state.mSavedCursorCol = mCursorCol;
+ state.mSavedEffect = mEffect;
+ state.mSavedForeColor = mForeColor;
+ state.mSavedBackColor = mBackColor;
+ state.mSavedDecFlags = mCurrentDecSetFlags;
+ state.mUseLineDrawingG0 = mUseLineDrawingG0;
+ state.mUseLineDrawingG1 = mUseLineDrawingG1;
+ state.mUseLineDrawingUsesG0 = mUseLineDrawingUsesG0;
+ }
+
+ /** DECRS restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC. See {@link #saveCursor()}. */
+ private void restoreCursor() {
+ SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt;
+ setCursorRowCol(state.mSavedCursorRow, state.mSavedCursorCol);
+ mEffect = state.mSavedEffect;
+ mForeColor = state.mSavedForeColor;
+ mBackColor = state.mSavedBackColor;
+ int mask = (DECSET_BIT_AUTOWRAP | DECSET_BIT_ORIGIN_MODE);
+ mCurrentDecSetFlags = (mCurrentDecSetFlags & ~mask) | (state.mSavedDecFlags & mask);
+ mUseLineDrawingG0 = state.mUseLineDrawingG0;
+ mUseLineDrawingG1 = state.mUseLineDrawingG1;
+ mUseLineDrawingUsesG0 = state.mUseLineDrawingUsesG0;
+ }
+
+ /** Following a CSI - Control Sequence Introducer, "\033[". {@link #ESC_CSI}. */
+ private void doCsi(int b) {
+ switch (b) {
+ case '!':
+ continueSequence(ESC_CSI_EXCLAMATION);
+ break;
+ case '"':
+ continueSequence(ESC_CSI_DOUBLE_QUOTE);
+ break;
+ case '\'':
+ continueSequence(ESC_CSI_SINGLE_QUOTE);
+ break;
+ case '$':
+ continueSequence(ESC_CSI_DOLLAR);
+ break;
+ case '*':
+ continueSequence(ESC_CSI_ARGS_ASTERIX);
+ break;
+ case '@': {
+ // "CSI{n}@" - Insert ${n} space characters (ICH) - http://www.vt100.net/docs/vt510-rm/ICH.
+ mAboutToAutoWrap = false;
+ int columnsAfterCursor = mColumns - mCursorCol;
+ int spacesToInsert = Math.min(getArg0(1), columnsAfterCursor);
+ int charsToMove = columnsAfterCursor - spacesToInsert;
+ mScreen.blockCopy(mCursorCol, mCursorRow, charsToMove, 1, mCursorCol + spacesToInsert, mCursorRow);
+ blockClear(mCursorCol, mCursorRow, spacesToInsert);
+ }
+ break;
+ case 'A': // "CSI${n}A" - Cursor up (CUU) ${n} rows.
+ setCursorRow(Math.max(0, mCursorRow - getArg0(1)));
+ break;
+ case 'B': // "CSI${n}B" - Cursor down (CUD) ${n} rows.
+ setCursorRow(Math.min(mRows - 1, mCursorRow + getArg0(1)));
+ break;
+ case 'C': // "CSI${n}C" - Cursor forward (CUF).
+ case 'a': // "CSI${n}a" - Horizontal position relative (HPR). From ISO-6428/ECMA-48.
+ setCursorCol(Math.min(mRightMargin - 1, mCursorCol + getArg0(1)));
+ break;
+ case 'D': // "CSI${n}D" - Cursor backward (CUB) ${n} columns.
+ setCursorCol(Math.max(mLeftMargin, mCursorCol - getArg0(1)));
+ break;
+ case 'E': // "CSI{n}E - Cursor Next Line (CNL). From ISO-6428/ECMA-48.
+ setCursorPosition(0, mCursorRow + getArg0(1));
+ break;
+ case 'F': // "CSI{n}F - Cursor Previous Line (CPL). From ISO-6428/ECMA-48.
+ setCursorPosition(0, mCursorRow - getArg0(1));
+ break;
+ case 'G': // "CSI${n}G" - Cursor horizontal absolute (CHA) to column ${n}.
+ setCursorCol(Math.min(Math.max(1, getArg0(1)), mColumns) - 1);
+ break;
+ case 'H': // "${CSI}${ROW};${COLUMN}H" - Cursor position (CUP).
+ case 'f': // "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP).
+ setCursorPosition(getArg1(1) - 1, getArg0(1) - 1);
+ break;
+ case 'I': // Cursor Horizontal Forward Tabulation (CHT). Move the active position n tabs forward.
+ setCursorCol(nextTabStop(getArg0(1)));
+ break;
+ case 'J': // "${CSI}${0,1,2,3}J" - Erase in Display (ED)
+ // ED ignores the scrolling margins.
+ switch (getArg0(0)) {
+ case 0: // Erase from the active position to the end of the screen, inclusive (default).
+ blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol);
+ blockClear(0, mCursorRow + 1, mColumns, mRows - (mCursorRow + 1));
+ break;
+ case 1: // Erase from start of the screen to the active position, inclusive.
+ blockClear(0, 0, mColumns, mCursorRow);
+ blockClear(0, mCursorRow, mCursorCol + 1);
+ break;
+ case 2: // Erase all of the display - all lines are erased, changed to single-width, and the cursor does not
+ // move..
+ blockClear(0, 0, mColumns, mRows);
+ break;
+ case 3: // Delete all lines saved in the scrollback buffer (xterm etc)
+ mMainBuffer.clearTranscript();
+ break;
+ default:
+ unknownSequence(b);
+ return;
+ }
+ mAboutToAutoWrap = false;
+ break;
+ case 'K': // "CSI{n}K" - Erase in line (EL).
+ switch (getArg0(0)) {
+ case 0: // Erase from the cursor to the end of the line, inclusive (default)
+ blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol);
+ break;
+ case 1: // Erase from the start of the screen to the cursor, inclusive.
+ blockClear(0, mCursorRow, mCursorCol + 1);
+ break;
+ case 2: // Erase all of the line.
+ blockClear(0, mCursorRow, mColumns);
+ break;
+ default:
+ unknownSequence(b);
+ return;
+ }
+ mAboutToAutoWrap = false;
+ break;
+ case 'L': // "${CSI}{N}L" - insert ${N} lines (IL).
+ {
+ int linesAfterCursor = mBottomMargin - mCursorRow;
+ int linesToInsert = Math.min(getArg0(1), linesAfterCursor);
+ int linesToMove = linesAfterCursor - linesToInsert;
+ mScreen.blockCopy(0, mCursorRow, mColumns, linesToMove, 0, mCursorRow + linesToInsert);
+ blockClear(0, mCursorRow, mColumns, linesToInsert);
+ }
+ break;
+ case 'M': // "${CSI}${N}M" - delete N lines (DL).
+ {
+ mAboutToAutoWrap = false;
+ int linesAfterCursor = mBottomMargin - mCursorRow;
+ int linesToDelete = Math.min(getArg0(1), linesAfterCursor);
+ int linesToMove = linesAfterCursor - linesToDelete;
+ mScreen.blockCopy(0, mCursorRow + linesToDelete, mColumns, linesToMove, 0, mCursorRow);
+ blockClear(0, mCursorRow + linesToMove, mColumns, linesToDelete);
+ }
+ break;
+ case 'P': // "${CSI}{N}P" - delete ${N} characters (DCH).
+ {
+ // http://www.vt100.net/docs/vt510-rm/DCH: "If ${N} is greater than the number of characters between the
+ // cursor and the right margin, then DCH only deletes the remaining characters.
+ // As characters are deleted, the remaining characters between the cursor and right margin move to the left.
+ // Character attributes move with the characters. The terminal adds blank spaces with no visual character
+ // attributes at the right margin. DCH has no effect outside the scrolling margins."
+ mAboutToAutoWrap = false;
+ int cellsAfterCursor = mColumns - mCursorCol;
+ int cellsToDelete = Math.min(getArg0(1), cellsAfterCursor);
+ int cellsToMove = cellsAfterCursor - cellsToDelete;
+ mScreen.blockCopy(mCursorCol + cellsToDelete, mCursorRow, cellsToMove, 1, mCursorCol, mCursorRow);
+ blockClear(mCursorCol + cellsToMove, mCursorRow, cellsToDelete);
+ }
+ break;
+ case 'S': { // "${CSI}${N}S" - scroll up ${N} lines (default = 1) (SU).
+ final int linesToScroll = getArg0(1);
+ for (int i = 0; i < linesToScroll; i++)
+ scrollDownOneLine();
+ break;
+ }
+ case 'T':
+ if (mArgIndex == 0) {
+ // "${CSI}${N}T" - Scroll down N lines (default = 1) (SD).
+ // http://vt100.net/docs/vt510-rm/SD: "N is the number of lines to move the user window up in page
+ // memory. N new lines appear at the top of the display. N old lines disappear at the bottom of the
+ // display. You cannot pan past the top margin of the current page".
+ final int linesToScrollArg = getArg0(1);
+ final int linesBetweenTopAndBottomMargins = mBottomMargin - mTopMargin;
+ final int linesToScroll = Math.min(linesBetweenTopAndBottomMargins, linesToScrollArg);
+ mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesBetweenTopAndBottomMargins - linesToScroll, mLeftMargin, mTopMargin + linesToScroll);
+ blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesToScroll);
+ } else {
+ // "${CSI}${func};${startx};${starty};${firstrow};${lastrow}T" - initiate highlight mouse tracking.
+ unimplementedSequence(b);
+ }
+ break;
+ case 'X': // "${CSI}${N}X" - Erase ${N:=1} character(s) (ECH). FIXME: Clears character attributes?
+ mAboutToAutoWrap = false;
+ mScreen.blockSet(mCursorCol, mCursorRow, Math.min(getArg0(1), mColumns - mCursorCol), 1, ' ', getStyle());
+ break;
+ case 'Z': // Cursor Backward Tabulation (CBT). Move the active position n tabs backward.
+ int numberOfTabs = getArg0(1);
+ int newCol = mLeftMargin;
+ for (int i = mCursorCol - 1; i >= 0; i--)
+ if (mTabStop[i]) {
+ if (--numberOfTabs == 0) {
+ newCol = Math.max(i, mLeftMargin);
+ break;
+ }
+ }
+ mCursorCol = newCol;
+ break;
+ case '?': // Esc [ ? -- start of a private parameter byte
+ continueSequence(ESC_CSI_QUESTIONMARK);
+ break;
+ case '>': // "Esc [ >" -- start of a private parameter byte
+ continueSequence(ESC_CSI_BIGGERTHAN);
+ break;
+ case '<': // "Esc [ <" -- start of a private parameter byte
+ case '=': // "Esc [ =" -- start of a private parameter byte
+ continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE);
+ break;
+ case '`': // Horizontal position absolute (HPA - http://www.vt100.net/docs/vt510-rm/HPA).
+ setCursorColRespectingOriginMode(getArg0(1) - 1);
+ break;
+ case 'b': // Repeat the preceding graphic character Ps times (REP).
+ if (mLastEmittedCodePoint == -1) break;
+ final int numRepeat = getArg0(1);
+ for (int i = 0; i < numRepeat; i++) emitCodePoint(mLastEmittedCodePoint);
+ break;
+ case 'c': // Primary Device Attributes (http://www.vt100.net/docs/vt510-rm/DA1) if argument is missing or zero.
+ // The important part that may still be used by some (tmux stores this value but does not currently use it)
+ // is the first response parameter identifying the terminal service class, where we send 64 for "vt420".
+ // This is followed by a list of attributes which is probably unused by applications. Send like xterm.
+ if (getArg0(0) == 0) mSession.write("\033[?64;1;2;6;9;15;18;21;22c");
+ break;
+ case 'd': // ESC [ Pn d - Vert Position Absolute
+ setCursorRow(Math.min(Math.max(1, getArg0(1)), mRows) - 1);
+ break;
+ case 'e': // Vertical Position Relative (VPR). From ISO-6429 (ECMA-48).
+ setCursorPosition(mCursorCol, mCursorRow + getArg0(1));
+ break;
+ // case 'f': "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). Grouped with case 'H'.
+ case 'g': // Clear tab stop
+ switch (getArg0(0)) {
+ case 0:
+ mTabStop[mCursorCol] = false;
+ break;
+ case 3:
+ for (int i = 0; i < mColumns; i++) {
+ mTabStop[i] = false;
+ }
+ break;
+ default:
+ // Specified to have no effect.
+ break;
+ }
+ break;
+ case 'h': // Set Mode
+ doSetMode(true);
+ break;
+ case 'l': // Reset Mode
+ doSetMode(false);
+ break;
+ case 'm': // Esc [ Pn m - character attributes. (can have up to 16 numerical arguments)
+ selectGraphicRendition();
+ break;
+ case 'n': // Esc [ Pn n - ECMA-48 Status Report Commands
+ // sendDeviceAttributes()
+ switch (getArg0(0)) {
+ case 5: // Device status report (DSR):
+ // Answer is ESC [ 0 n (Terminal OK).
+ byte[] dsr = {(byte) 27, (byte) '[', (byte) '0', (byte) 'n'};
+ mSession.write(dsr, 0, dsr.length);
+ break;
+ case 6: // Cursor position report (CPR):
+ // Answer is ESC [ y ; x R, where x,y is
+ // the cursor location.
+ mSession.write(String.format(Locale.US, "\033[%d;%dR", mCursorRow + 1, mCursorCol + 1));
+ break;
+ default:
+ break;
+ }
+ break;
+ case 'r': // "CSI${top};${bottom}r" - set top and bottom Margins (DECSTBM).
+ {
+ // https://vt100.net/docs/vt510-rm/DECSTBM.html
+ // The top margin defaults to 1, the bottom margin defaults to mRows.
+ // The escape sequence numbers top 1..23, but we number top 0..22.
+ // The escape sequence numbers bottom 2..24, and so do we (because we use a zero based numbering
+ // scheme, but we store the first line below the bottom-most scrolling line.
+ // As a result, we adjust the top line by -1, but we leave the bottom line alone.
+ // Also require that top + 2 <= bottom.
+ mTopMargin = Math.max(0, Math.min(getArg0(1) - 1, mRows - 2));
+ mBottomMargin = Math.max(mTopMargin + 2, Math.min(getArg1(mRows), mRows));
+
+ // DECSTBM moves the cursor to column 1, line 1 of the page respecting origin mode.
+ setCursorPosition(0, 0);
+ }
+ break;
+ case 's':
+ if (isDecsetInternalBitSet(DECSET_BIT_LEFTRIGHT_MARGIN_MODE)) {
+ // Set left and right margins (DECSLRM - http://www.vt100.net/docs/vt510-rm/DECSLRM).
+ mLeftMargin = Math.min(getArg0(1) - 1, mColumns - 2);
+ mRightMargin = Math.max(mLeftMargin + 1, Math.min(getArg1(mColumns), mColumns));
+ // DECSLRM moves the cursor to column 1, line 1 of the page.
+ setCursorPosition(0, 0);
+ } else {
+ // Save cursor (ANSI.SYS), available only when DECLRMM is disabled.
+ saveCursor();
+ }
+ break;
+ case 't': // Window manipulation (from dtterm, as well as extensions)
+ switch (getArg0(0)) {
+ case 11: // Report xterm window state. If the xterm window is open (non-iconified), it returns CSI 1 t .
+ mSession.write("\033[1t");
+ break;
+ case 13: // Report xterm window position. Result is CSI 3 ; x ; y t
+ mSession.write("\033[3;0;0t");
+ break;
+ case 14: // Report xterm window in pixels. Result is CSI 4 ; height ; width t
+ mSession.write(String.format(Locale.US, "\033[4;%d;%dt", mRows * mCellHeightPixels, mColumns * mCellWidthPixels));
+ break;
+ case 16: // Report xterm character cell size in pixels. Result is CSI 6 ; height ; width t
+ mSession.write(String.format(Locale.US, "\033[6;%d;%dt", mCellHeightPixels, mCellWidthPixels));
+ break;
+ case 18: // Report the size of the text area in characters. Result is CSI 8 ; height ; width t
+ mSession.write(String.format(Locale.US, "\033[8;%d;%dt", mRows, mColumns));
+ break;
+ case 19: // Report the size of the screen in characters. Result is CSI 9 ; height ; width t
+ // We report the same size as the view, since it's the view really isn't resizable from the shell.
+ mSession.write(String.format(Locale.US, "\033[9;%d;%dt", mRows, mColumns));
+ break;
+ case 20: // Report xterm windows icon label. Result is OSC L label ST. Disabled due to security concerns:
+ mSession.write("\033]LIconLabel\033\\");
+ break;
+ case 21: // Report xterm windows title. Result is OSC l label ST. Disabled due to security concerns:
+ mSession.write("\033]l\033\\");
+ break;
+ case 22:
+ // 22;0 -> Save xterm icon and window title on stack.
+ // 22;1 -> Save xterm icon title on stack.
+ // 22;2 -> Save xterm window title on stack.
+ mTitleStack.push(mTitle);
+ if (mTitleStack.size() > 20) {
+ // Limit size
+ mTitleStack.remove(0);
+ }
+ break;
+ case 23: // Like 22 above but restore from stack.
+ if (!mTitleStack.isEmpty()) setTitle(mTitleStack.pop());
+ break;
+ default:
+ // Ignore window manipulation.
+ break;
+ }
+ break;
+ case 'u': // Restore cursor (ANSI.SYS).
+ restoreCursor();
+ break;
+ case ' ':
+ continueSequence(ESC_CSI_ARGS_SPACE);
+ break;
+ default:
+ parseArg(b);
+ break;
+ }
+ }
+
+ /** Select Graphic Rendition (SGR) - see http://en.wikipedia.org/wiki/ANSI_escape_code#graphics. */
+ private void selectGraphicRendition() {
+ if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1;
+ for (int i = 0; i <= mArgIndex; i++) {
+ // Skip leading sub parameters:
+ if ((mArgsSubParamsBitSet & (1 << i)) != 0) {
+ continue;
+ }
+
+ int code = getArg(i, 0, false);
+ if (code < 0) {
+ if (mArgIndex > 0) {
+ continue;
+ } else {
+ code = 0;
+ }
+ }
+ if (code == 0) { // reset
+ mForeColor = TextStyle.COLOR_INDEX_FOREGROUND;
+ mBackColor = TextStyle.COLOR_INDEX_BACKGROUND;
+ mEffect = 0;
+ } else if (code == 1) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BOLD;
+ } else if (code == 2) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_DIM;
+ } else if (code == 3) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_ITALIC;
+ } else if (code == 4) {
+ if (i + 1 <= mArgIndex && ((mArgsSubParamsBitSet & (1 << (i + 1))) != 0)) {
+ // Sub parameter, see https://sw.kovidgoyal.net/kitty/underlines/
+ i++;
+ if (mArgs[i] == 0) {
+ // No underline.
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ } else {
+ // Different variations of underlines: https://sw.kovidgoyal.net/kitty/underlines/
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ }
+ } else {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ }
+ } else if (code == 5) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BLINK;
+ } else if (code == 7) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVERSE;
+ } else if (code == 8) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE;
+ } else if (code == 9) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH;
+ } else if (code == 10) {
+ // Exit alt charset (TERM=linux) - ignore.
+ } else if (code == 11) {
+ // Enter alt charset (TERM=linux) - ignore.
+ } else if (code == 22) { // Normal color or intensity, neither bright, bold nor faint.
+ mEffect &= ~(TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_DIM);
+ } else if (code == 23) { // not italic, but rarely used as such; clears standout with TERM=screen
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_ITALIC;
+ } else if (code == 24) { // underline: none
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ } else if (code == 25) { // blink: none
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_BLINK;
+ } else if (code == 27) { // image: positive
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVERSE;
+ } else if (code == 28) {
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE;
+ } else if (code == 29) {
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH;
+ } else if (code >= 30 && code <= 37) {
+ mForeColor = code - 30;
+ } else if (code == 38 || code == 48 || code == 58) {
+ // Extended set foreground(38)/background(48)/underline(58) color.
+ // This is followed by either "2;$R;$G;$B" to set a 24-bit color or
+ // "5;$INDEX" to set an indexed color.
+ if (i + 2 > mArgIndex) continue;
+ int firstArg = mArgs[i + 1];
+ if (firstArg == 2) {
+ if (i + 4 > mArgIndex) {
+ Logger.logWarn(mClient, LOG_TAG, "Too few CSI" + code + ";2 RGB arguments");
+ } else {
+ int red = getArg(i + 2, 0, false);
+ int green = getArg(i + 3, 0, false);
+ int blue = getArg(i + 4, 0, false);
+
+ if (red < 0 || green < 0 || blue < 0 || red > 255 || green > 255 || blue > 255) {
+ finishSequenceAndLogError("Invalid RGB: " + red + "," + green + "," + blue);
+ } else {
+ int argbColor = 0xff_00_00_00 | (red << 16) | (green << 8) | blue;
+ switch (code) {
+ case 38: mForeColor = argbColor; break;
+ case 48: mBackColor = argbColor; break;
+ case 58: mUnderlineColor = argbColor; break;
+ }
+ }
+ i += 4; // "2;P_r;P_g;P_r"
+ }
+ } else if (firstArg == 5) {
+ int color = getArg(i + 2, 0, false);
+ i += 2; // "5;P_s"
+ if (color >= 0 && color < TextStyle.NUM_INDEXED_COLORS) {
+ switch (code) {
+ case 38: mForeColor = color; break;
+ case 48: mBackColor = color; break;
+ case 58: mUnderlineColor = color; break;
+ }
+ } else {
+ if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, "Invalid color index: " + color);
+ }
+ } else {
+ finishSequenceAndLogError("Invalid ISO-8613-3 SGR first argument: " + firstArg);
+ }
+ } else if (code == 39) { // Set default foreground color.
+ mForeColor = TextStyle.COLOR_INDEX_FOREGROUND;
+ } else if (code >= 40 && code <= 47) { // Set background color.
+ mBackColor = code - 40;
+ } else if (code == 49) { // Set default background color.
+ mBackColor = TextStyle.COLOR_INDEX_BACKGROUND;
+ } else if (code == 59) { // Set default underline color.
+ mUnderlineColor = TextStyle.COLOR_INDEX_FOREGROUND;
+ } else if (code >= 90 && code <= 97) { // Bright foreground colors (aixterm codes).
+ mForeColor = code - 90 + 8;
+ } else if (code >= 100 && code <= 107) { // Bright background color (aixterm codes).
+ mBackColor = code - 100 + 8;
+ } else {
+ if (LOG_ESCAPE_SEQUENCES)
+ Logger.logWarn(mClient, LOG_TAG, String.format("SGR unknown code %d", code));
+ }
+ }
+ }
+
+ private void doOsc(int b) {
+ switch (b) {
+ case 7: // Bell.
+ doOscSetTextParameters("\007");
+ break;
+ case 27: // Escape.
+ continueSequence(ESC_OSC_ESC);
+ break;
+ default:
+ collectOSCArgs(b);
+ break;
+ }
+ }
+
+ private void doOscEsc(int b) {
+ switch (b) {
+ case '\\':
+ doOscSetTextParameters("\033\\");
+ break;
+ default:
+ // The ESC character was not followed by a \, so insert the ESC and
+ // the current character in arg buffer.
+ collectOSCArgs(27);
+ collectOSCArgs(b);
+ continueSequence(ESC_OSC);
+ break;
+ }
+ }
+
+ /** An Operating System Controls (OSC) Set Text Parameters. May come here from BEL or ST. */
+ private void doOscSetTextParameters(String bellOrStringTerminator) {
+ int value = -1;
+ String textParameter = "";
+ // Extract initial $value from initial "$value;..." string.
+ for (int mOSCArgTokenizerIndex = 0; mOSCArgTokenizerIndex < mOSCOrDeviceControlArgs.length(); mOSCArgTokenizerIndex++) {
+ char b = mOSCOrDeviceControlArgs.charAt(mOSCArgTokenizerIndex);
+ if (b == ';') {
+ textParameter = mOSCOrDeviceControlArgs.substring(mOSCArgTokenizerIndex + 1);
+ break;
+ } else if (b >= '0' && b <= '9') {
+ value = ((value < 0) ? 0 : value * 10) + (b - '0');
+ } else {
+ unknownSequence(b);
+ return;
+ }
+ }
+
+ switch (value) {
+ case 0: // Change icon name and window title to T.
+ case 1: // Change icon name to T.
+ case 2: // Change window title to T.
+ setTitle(textParameter);
+ break;
+ case 4:
+ // P s = 4 ; c ; spec → Change Color Number c to the color specified by spec. This can be a name or RGB
+ // specification as per XParseColor. Any number of c name pairs may be given. The color numbers correspond
+ // to the ANSI colors 0-7, their bright versions 8-15, and if supported, the remainder of the 88-color or
+ // 256-color table.
+ // If a "?" is given rather than a name or RGB specification, xterm replies with a control sequence of the
+ // same form which can be used to set the corresponding color. Because more than one pair of color number
+ // and specification can be given in one control sequence, xterm can make more than one reply.
+ int colorIndex = -1;
+ int parsingPairStart = -1;
+ for (int i = 0; ; i++) {
+ boolean endOfInput = i == textParameter.length();
+ char b = endOfInput ? ';' : textParameter.charAt(i);
+ if (b == ';') {
+ if (parsingPairStart < 0) {
+ parsingPairStart = i + 1;
+ } else {
+ if (colorIndex < 0 || colorIndex > 255) {
+ unknownSequence(b);
+ return;
+ } else {
+ mColors.tryParseColor(colorIndex, textParameter.substring(parsingPairStart, i));
+ mSession.onColorsChanged();
+ colorIndex = -1;
+ parsingPairStart = -1;
+ }
+ }
+ } else if (parsingPairStart >= 0) {
+ // We have passed a color index and are now going through color spec.
+ } else if (parsingPairStart < 0 && (b >= '0' && b <= '9')) {
+ colorIndex = ((colorIndex < 0) ? 0 : colorIndex * 10) + (b - '0');
+ } else {
+ unknownSequence(b);
+ return;
+ }
+ if (endOfInput) break;
+ }
+ break;
+ case 10: // Set foreground color.
+ case 11: // Set background color.
+ case 12: // Set cursor color.
+ int specialIndex = TextStyle.COLOR_INDEX_FOREGROUND + (value - 10);
+ int lastSemiIndex = 0;
+ for (int charIndex = 0; ; charIndex++) {
+ boolean endOfInput = charIndex == textParameter.length();
+ if (endOfInput || textParameter.charAt(charIndex) == ';') {
+ try {
+ String colorSpec = textParameter.substring(lastSemiIndex, charIndex);
+ if ("?".equals(colorSpec)) {
+ // Report current color in the same format xterm and gnome-terminal does.
+ int rgb = mColors.mCurrentColors[specialIndex];
+ int r = (65535 * ((rgb & 0x00FF0000) >> 16)) / 255;
+ int g = (65535 * ((rgb & 0x0000FF00) >> 8)) / 255;
+ int b = (65535 * ((rgb & 0x000000FF))) / 255;
+ mSession.write("\033]" + value + ";rgb:" + String.format(Locale.US, "%04x", r) + "/" + String.format(Locale.US, "%04x", g) + "/"
+ + String.format(Locale.US, "%04x", b) + bellOrStringTerminator);
+ } else {
+ mColors.tryParseColor(specialIndex, colorSpec);
+ mSession.onColorsChanged();
+ }
+ specialIndex++;
+ if (endOfInput || (specialIndex > TextStyle.COLOR_INDEX_CURSOR) || ++charIndex >= textParameter.length())
+ break;
+ lastSemiIndex = charIndex;
+ } catch (NumberFormatException e) {
+ // Ignore.
+ }
+ }
+ }
+ break;
+ case 52: // Manipulate Selection Data. Skip the optional first selection parameter(s).
+ int startIndex = textParameter.indexOf(";") + 1;
+ try {
+ String clipboardText = new String(Base64.decode(textParameter.substring(startIndex), 0), StandardCharsets.UTF_8);
+ mSession.onCopyTextToClipboard(clipboardText);
+ } catch (Exception e) {
+ Logger.logError(mClient, LOG_TAG, "OSC Manipulate selection, invalid string '" + 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,
+ // their bright versions 8-15, and if supported, the remainder of the 88-color or 256-color table. If no
+ // parameters are given, the entire table will be reset.
+ if (textParameter.isEmpty()) {
+ mColors.reset();
+ mSession.onColorsChanged();
+ } else {
+ int lastIndex = 0;
+ for (int charIndex = 0; ; charIndex++) {
+ boolean endOfInput = charIndex == textParameter.length();
+ if (endOfInput || textParameter.charAt(charIndex) == ';') {
+ try {
+ int colorToReset = Integer.parseInt(textParameter.substring(lastIndex, charIndex));
+ mColors.reset(colorToReset);
+ mSession.onColorsChanged();
+ if (endOfInput) break;
+ charIndex++;
+ lastIndex = charIndex;
+ } catch (NumberFormatException e) {
+ // Ignore.
+ }
+ }
+ }
+ }
+ break;
+ case 110: // Reset foreground color.
+ case 111: // Reset background color.
+ case 112: // Reset cursor color.
+ mColors.reset(TextStyle.COLOR_INDEX_FOREGROUND + (value - 110));
+ mSession.onColorsChanged();
+ break;
+ case 119: // Reset highlight color.
+ break;
+ default:
+ unknownParameter(value);
+ break;
+ }
+ finishSequence();
+ }
+
+ private void blockClear(int sx, int sy, int w) {
+ blockClear(sx, sy, w, 1);
+ }
+
+ private void blockClear(int sx, int sy, int w, int h) {
+ mScreen.blockSet(sx, sy, w, h, ' ', getStyle());
+ }
+
+ private long getStyle() {
+ return TextStyle.encode(mForeColor, mBackColor, mEffect);
+ }
+
+ /** "CSI P_m h" for set or "CSI P_m l" for reset ANSI mode. */
+ private void doSetMode(boolean newValue) {
+ int modeBit = getArg0(0);
+ switch (modeBit) {
+ case 4: // Set="Insert Mode". Reset="Replace Mode". (IRM).
+ mInsertMode = newValue;
+ break;
+ case 20: // Normal Linefeed (LNM).
+ unknownParameter(modeBit);
+ // http://www.vt100.net/docs/vt510-rm/LNM
+ break;
+ case 34:
+ // Normal cursor visibility - when using TERM=screen, see
+ // http://www.gnu.org/software/screen/manual/html_node/Control-Sequences.html
+ break;
+ default:
+ unknownParameter(modeBit);
+ break;
+ }
+ }
+
+ /**
+ * NOTE: The parameters of this function respect the {@link #DECSET_BIT_ORIGIN_MODE}. Use
+ * {@link #setCursorRowCol(int, int)} for absolute pos.
+ */
+ private void setCursorPosition(int x, int y) {
+ boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE);
+ int effectiveTopMargin = originMode ? mTopMargin : 0;
+ int effectiveBottomMargin = originMode ? mBottomMargin : mRows;
+ int effectiveLeftMargin = originMode ? mLeftMargin : 0;
+ int effectiveRightMargin = originMode ? mRightMargin : mColumns;
+ int newRow = Math.max(effectiveTopMargin, Math.min(effectiveTopMargin + y, effectiveBottomMargin - 1));
+ int newCol = Math.max(effectiveLeftMargin, Math.min(effectiveLeftMargin + x, effectiveRightMargin - 1));
+ setCursorRowCol(newRow, newCol);
+ }
+
+ private void scrollDownOneLine() {
+ mScrollCounter++;
+ long currentStyle = getStyle();
+ if (mLeftMargin != 0 || mRightMargin != mColumns) {
+ // Horizontal margin: Do not put anything into scroll history, just non-margin part of screen up.
+ mScreen.blockCopy(mLeftMargin, mTopMargin + 1, mRightMargin - mLeftMargin, mBottomMargin - mTopMargin - 1, mLeftMargin, mTopMargin);
+ // .. and blank bottom row between margins:
+ mScreen.blockSet(mLeftMargin, mBottomMargin - 1, mRightMargin - mLeftMargin, 1, ' ', currentStyle);
+ } else {
+ mScreen.scrollDownOneLine(mTopMargin, mBottomMargin, currentStyle);
+ }
+ }
+
+ /**
+ * Process the next ASCII character of a parameter.
+ *
+ *
You must use the ; character to separate parameters and : to separate sub-parameters.
+ *
+ *
Parameter characters modify the action or interpretation of the sequence. Originally
+ * you can use up to 16 parameters per sequence, but following at least xterm and alacritty
+ * we use a common space for parameters and sub-parameters, allowing 32 in total.
+ *
+ *
All parameters are unsigned, positive decimal integers, with the most significant
+ * digit sent first. Any parameter greater than 9999 (decimal) is set to 9999
+ * (decimal). If you do not specify a value, a 0 value is assumed. A 0 value
+ * or omitted parameter indicates a default value for the sequence. For most
+ * sequences, the default value is 1.
+ *
+ *
References:
+ * VT510 Video Terminal Programmer Information: Control Sequences
+ * alacritty/vte: Implement colon separated CSI parameters
+ * */
+ private void parseArg(int b) {
+ if (b >= '0' && b <= '9') {
+ if (mArgIndex < mArgs.length) {
+ int oldValue = mArgs[mArgIndex];
+ int thisDigit = b - '0';
+ int value;
+ if (oldValue >= 0) {
+ value = oldValue * 10 + thisDigit;
+ } else {
+ value = thisDigit;
+ }
+ if (value > 9999)
+ value = 9999;
+ mArgs[mArgIndex] = value;
+ }
+ continueSequence(mEscapeState);
+ } else if (b == ';' || b == ':') {
+ if (mArgIndex + 1 < mArgs.length) {
+ mArgIndex++;
+ if (b == ':') {
+ mArgsSubParamsBitSet |= 1 << mArgIndex;
+ }
+ } else {
+ logError("Too many parameters when in state: " + mEscapeState);
+ }
+ continueSequence(mEscapeState);
+ } else {
+ unknownSequence(b);
+ }
+ }
+
+ private int getArg0(int defaultValue) {
+ return getArg(0, defaultValue, true);
+ }
+
+ private int getArg1(int defaultValue) {
+ return getArg(1, defaultValue, true);
+ }
+
+ private int getArg(int index, int defaultValue, boolean treatZeroAsDefault) {
+ int result = mArgs[index];
+ if (result < 0 || (result == 0 && treatZeroAsDefault)) {
+ result = defaultValue;
+ }
+ return result;
+ }
+
+ private void collectOSCArgs(int b) {
+ if (mOSCOrDeviceControlArgs.length() < MAX_OSC_STRING_LENGTH) {
+ mOSCOrDeviceControlArgs.appendCodePoint(b);
+ continueSequence(mEscapeState);
+ } else {
+ unknownSequence(b);
+ }
+ }
+
+ private void unimplementedSequence(int b) {
+ logError("Unimplemented sequence char '" + (char) b + "' (U+" + String.format("%04x", b) + ")");
+ finishSequence();
+ }
+
+ private void unknownSequence(int b) {
+ logError("Unknown sequence char '" + (char) b + "' (numeric value=" + b + ")");
+ finishSequence();
+ }
+
+ private void unknownParameter(int parameter) {
+ logError("Unknown parameter: " + parameter);
+ finishSequence();
+ }
+
+ private void logError(String errorType) {
+ if (LOG_ESCAPE_SEQUENCES) {
+ StringBuilder buf = new StringBuilder();
+ buf.append(errorType);
+ buf.append(", escapeState=");
+ buf.append(mEscapeState);
+ boolean firstArg = true;
+ if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1;
+ for (int i = 0; i <= mArgIndex; i++) {
+ int value = mArgs[i];
+ if (value >= 0) {
+ if (firstArg) {
+ firstArg = false;
+ buf.append(", args={");
+ } else {
+ buf.append(',');
+ }
+ buf.append(value);
+ }
+ }
+ if (!firstArg) buf.append('}');
+ finishSequenceAndLogError(buf.toString());
+ }
+ }
+
+ private void finishSequenceAndLogError(String error) {
+ if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, error);
+ finishSequence();
+ }
+
+ private void finishSequence() {
+ mEscapeState = ESC_NONE;
+ }
+
+ /**
+ * Send a Unicode code point to the screen.
+ *
+ * @param codePoint The code point of the character to display
+ */
+ private void emitCodePoint(int codePoint) {
+ mLastEmittedCodePoint = codePoint;
+ if (mUseLineDrawingUsesG0 ? mUseLineDrawingG0 : mUseLineDrawingG1) {
+ // http://www.vt100.net/docs/vt102-ug/table5-15.html.
+ switch (codePoint) {
+ case '_':
+ codePoint = ' '; // Blank.
+ break;
+ case '`':
+ codePoint = '◆'; // Diamond.
+ break;
+ case '0':
+ codePoint = '█'; // Solid block;
+ break;
+ case 'a':
+ codePoint = '▒'; // Checker board.
+ break;
+ case 'b':
+ codePoint = '␉'; // Horizontal tab.
+ break;
+ case 'c':
+ codePoint = '␌'; // Form feed.
+ break;
+ case 'd':
+ codePoint = '\r'; // Carriage return.
+ break;
+ case 'e':
+ codePoint = '␊'; // Linefeed.
+ break;
+ case 'f':
+ codePoint = '°'; // Degree.
+ break;
+ case 'g':
+ codePoint = '±'; // Plus-minus.
+ break;
+ case 'h':
+ codePoint = '\n'; // Newline.
+ break;
+ case 'i':
+ codePoint = '␋'; // Vertical tab.
+ break;
+ case 'j':
+ codePoint = '┘'; // Lower right corner.
+ break;
+ case 'k':
+ codePoint = '┐'; // Upper right corner.
+ break;
+ case 'l':
+ codePoint = '┌'; // Upper left corner.
+ break;
+ case 'm':
+ codePoint = '└'; // Left left corner.
+ break;
+ case 'n':
+ codePoint = '┼'; // Crossing lines.
+ break;
+ case 'o':
+ codePoint = '⎺'; // Horizontal line - scan 1.
+ break;
+ case 'p':
+ codePoint = '⎻'; // Horizontal line - scan 3.
+ break;
+ case 'q':
+ codePoint = '─'; // Horizontal line - scan 5.
+ break;
+ case 'r':
+ codePoint = '⎼'; // Horizontal line - scan 7.
+ break;
+ case 's':
+ codePoint = '⎽'; // Horizontal line - scan 9.
+ break;
+ case 't':
+ codePoint = '├'; // T facing rightwards.
+ break;
+ case 'u':
+ codePoint = '┤'; // T facing leftwards.
+ break;
+ case 'v':
+ codePoint = '┴'; // T facing upwards.
+ break;
+ case 'w':
+ codePoint = '┬'; // T facing downwards.
+ break;
+ case 'x':
+ codePoint = '│'; // Vertical line.
+ break;
+ case 'y':
+ codePoint = '≤'; // Less than or equal to.
+ break;
+ case 'z':
+ codePoint = '≥'; // Greater than or equal to.
+ break;
+ case '{':
+ codePoint = 'π'; // Pi.
+ break;
+ case '|':
+ codePoint = '≠'; // Not equal to.
+ break;
+ case '}':
+ codePoint = '£'; // UK pound.
+ break;
+ case '~':
+ codePoint = '·'; // Centered dot.
+ break;
+ }
+ }
+
+ final boolean autoWrap = isDecsetInternalBitSet(DECSET_BIT_AUTOWRAP);
+ final int displayWidth = WcWidth.width(codePoint);
+ final boolean cursorInLastColumn = mCursorCol == mRightMargin - 1;
+
+ if (autoWrap) {
+ if (cursorInLastColumn && ((mAboutToAutoWrap && displayWidth == 1) || displayWidth == 2)) {
+ mScreen.setLineWrap(mCursorRow);
+ mCursorCol = mLeftMargin;
+ if (mCursorRow + 1 < mBottomMargin) {
+ mCursorRow++;
+ } else {
+ scrollDownOneLine();
+ }
+ }
+ } else if (cursorInLastColumn && displayWidth == 2) {
+ // The behaviour when a wide character is output with cursor in the last column when
+ // autowrap is disabled is not obvious - it's ignored here.
+ return;
+ }
+
+ if (mInsertMode && displayWidth > 0) {
+ // Move character to right one space.
+ int destCol = mCursorCol + displayWidth;
+ if (destCol < mRightMargin)
+ mScreen.blockCopy(mCursorCol, mCursorRow, mRightMargin - destCol, 1, destCol, mCursorRow);
+ }
+
+ int offsetDueToCombiningChar = ((displayWidth <= 0 && mCursorCol > 0 && !mAboutToAutoWrap) ? 1 : 0);
+ int column = mCursorCol - offsetDueToCombiningChar;
+
+ // Fix TerminalRow.setChar() ArrayIndexOutOfBoundsException index=-1 exception reported
+ // The offsetDueToCombiningChar would never be 1 if mCursorCol was 0 to get column/index=-1,
+ // 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());
+
+ if (autoWrap && displayWidth > 0)
+ mAboutToAutoWrap = (mCursorCol == mRightMargin - displayWidth);
+
+ mCursorCol = Math.min(mCursorCol + displayWidth, mRightMargin - 1);
+ }
+
+ private void setCursorRow(int row) {
+ mCursorRow = row;
+ mAboutToAutoWrap = false;
+ }
+
+ private void setCursorCol(int col) {
+ mCursorCol = col;
+ mAboutToAutoWrap = false;
+ }
+
+ /** Set the cursor mode, but limit it to margins if {@link #DECSET_BIT_ORIGIN_MODE} is enabled. */
+ private void setCursorColRespectingOriginMode(int col) {
+ setCursorPosition(col, mCursorRow);
+ }
+
+ /** TODO: Better name, distinguished from {@link #setCursorPosition(int, int)} by not regarding origin mode. */
+ private void setCursorRowCol(int row, int col) {
+ mCursorRow = Math.max(0, Math.min(row, mRows - 1));
+ mCursorCol = Math.max(0, Math.min(col, mColumns - 1));
+ mAboutToAutoWrap = false;
+ }
+
+ public int getScrollCounter() {
+ return mScrollCounter;
+ }
+
+ public void clearScrollCounter() {
+ mScrollCounter = 0;
+ }
+
+ public boolean isAutoScrollDisabled() {
+ return mAutoScrollDisabled;
+ }
+
+ public void toggleAutoScrollDisabled() {
+ mAutoScrollDisabled = !mAutoScrollDisabled;
+ }
+
+
+ /** Reset terminal state so user can interact with it regardless of present state. */
+ public void reset() {
+ setCursorStyle();
+ mArgIndex = 0;
+ mContinueSequence = false;
+ mEscapeState = ESC_NONE;
+ mInsertMode = false;
+ mTopMargin = mLeftMargin = 0;
+ mBottomMargin = mRows;
+ mRightMargin = mColumns;
+ mAboutToAutoWrap = false;
+ mForeColor = mSavedStateMain.mSavedForeColor = mSavedStateAlt.mSavedForeColor = TextStyle.COLOR_INDEX_FOREGROUND;
+ mBackColor = mSavedStateMain.mSavedBackColor = mSavedStateAlt.mSavedBackColor = TextStyle.COLOR_INDEX_BACKGROUND;
+ setDefaultTabStops();
+
+ mUseLineDrawingG0 = mUseLineDrawingG1 = false;
+ mUseLineDrawingUsesG0 = true;
+
+ mSavedStateMain.mSavedCursorRow = mSavedStateMain.mSavedCursorCol = mSavedStateMain.mSavedEffect = mSavedStateMain.mSavedDecFlags = 0;
+ mSavedStateAlt.mSavedCursorRow = mSavedStateAlt.mSavedCursorCol = mSavedStateAlt.mSavedEffect = mSavedStateAlt.mSavedDecFlags = 0;
+ mCurrentDecSetFlags = 0;
+ // Initial wrap-around is not accurate but makes terminal more useful, especially on a small screen:
+ setDecsetinternalBit(DECSET_BIT_AUTOWRAP, true);
+ setDecsetinternalBit(DECSET_BIT_CURSOR_ENABLED, true);
+ mSavedDecSetFlags = mSavedStateMain.mSavedDecFlags = mSavedStateAlt.mSavedDecFlags = mCurrentDecSetFlags;
+
+ // XXX: Should we set terminal driver back to IUTF8 with termios?
+ mUtf8Index = mUtf8ToFollow = 0;
+
+ mColors.reset();
+ mSession.onColorsChanged();
+ }
+
+ public String getSelectedText(int x1, int y1, int x2, int y2) {
+ return mScreen.getSelectedText(x1, y1, x2, y2);
+ }
+
+ /** Get the terminal session's title (null if not set). */
+ public String getTitle() {
+ return mTitle;
+ }
+
+ /** Change the terminal session's title. */
+ private void setTitle(String newTitle) {
+ String oldTitle = mTitle;
+ mTitle = newTitle;
+ if (!Objects.equals(oldTitle, newTitle)) {
+ mSession.titleChanged(oldTitle, newTitle);
+ }
+ }
+
+ /** If DECSET 2004 is set, prefix paste with "\033[200~" and suffix with "\033[201~". */
+ public void paste(String text) {
+ // First: Always remove escape key and C1 control characters [0x80,0x9F]:
+ text = text.replaceAll("(\u001B|[\u0080-\u009F])", "");
+ // Second: Replace all newlines (\n) or CRLF (\r\n) with carriage returns (\r).
+ text = text.replaceAll("\r?\n", "\r");
+
+ // Then: Implement bracketed paste mode if enabled:
+ boolean bracketed = isDecsetInternalBitSet(DECSET_BIT_BRACKETED_PASTE_MODE);
+ if (bracketed) mSession.write("\033[200~");
+ mSession.write(text);
+ if (bracketed) mSession.write("\033[201~");
+ }
+
+ /** http://www.vt100.net/docs/vt510-rm/DECSC */
+ static final class SavedScreenState {
+ /** Saved state of the cursor position, Used to implement the save/restore cursor position escape sequences. */
+ int mSavedCursorRow, mSavedCursorCol;
+ int mSavedEffect, mSavedForeColor, mSavedBackColor;
+ int mSavedDecFlags;
+ boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true;
+ }
+
+ @Override
+ public String toString() {
+ return "TerminalEmulator[size=" + mScreen.mColumns + "x" + mScreen.mScreenRows + ", margins={" + mTopMargin + "," + mRightMargin + "," + mBottomMargin
+ + "," + mLeftMargin + "}]";
+ }
+
+}
diff --git a/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java b/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java
new file mode 100644
index 0000000..305082a
--- /dev/null
+++ b/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java
@@ -0,0 +1,32 @@
+package com.termux.terminal;
+
+import java.nio.charset.StandardCharsets;
+
+/** A client which receives callbacks from events triggered by feeding input to a {@link TerminalEmulator}. */
+public abstract class TerminalOutput {
+
+ /** Write a string using the UTF-8 encoding to the terminal client. */
+ public final void write(String data) {
+ if (data == null) return;
+ byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
+ write(bytes, 0, bytes.length);
+ }
+
+ /** Write bytes to the terminal client. */
+ public abstract void write(byte[] data, int offset, int count);
+
+ /** Notify the terminal client that the terminal title has changed. */
+ public abstract void titleChanged(String oldTitle, String newTitle);
+
+ /** Notify the terminal client that text should be copied to clipboard. */
+ public abstract void onCopyTextToClipboard(String text);
+
+ /** Notify the terminal client that text should be pasted from clipboard. */
+ public abstract void onPasteTextFromClipboard();
+
+ /** Notify the terminal client that a bell character (ASCII 7, bell, BEL, \a, ^G)) has been received. */
+ public abstract void onBell();
+
+ public abstract void onColorsChanged();
+
+}
diff --git a/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java b/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java
new file mode 100644
index 0000000..d68dc32
--- /dev/null
+++ b/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java
@@ -0,0 +1,283 @@
+package com.termux.terminal;
+
+import java.util.Arrays;
+
+/**
+ * A row in a terminal, composed of a fixed number of cells.
+ *
+ * The text in the row is stored in a char[] array, {@link #mText}, for quick access during rendering.
+ */
+public final class TerminalRow {
+
+ private static final float SPARE_CAPACITY_FACTOR = 1.5f;
+
+ /**
+ * Max combining characters that can exist in a column, that are separate from the base character
+ * itself. Any additional combining characters will be ignored and not added to the column.
+ *
+ * There does not seem to be limit in unicode standard for max number of combination characters
+ * that can be combined but such characters are primarily under 10.
+ *
+ * "Section 3.6 Combination" of unicode standard contains combining characters info.
+ * - https://www.unicode.org/versions/Unicode15.0.0/ch03.pdf
+ * - https://en.wikipedia.org/wiki/Combining_character#Unicode_ranges
+ * - https://stackoverflow.com/questions/71237212/what-is-the-maximum-number-of-unicode-combined-characters-that-may-be-needed-to
+ *
+ * UAX15-D3 Stream-Safe Text Format limits to max 30 combining characters.
+ * > The value of 30 is chosen to be significantly beyond what is required for any linguistic or technical usage.
+ * > While it would have been feasible to chose a smaller number, this value provides a very wide margin,
+ * > yet is well within the buffer size limits of practical implementations.
+ * - https://unicode.org/reports/tr15/#Stream_Safe_Text_Format
+ * - https://stackoverflow.com/a/11983435/14686958
+ *
+ * We choose the value 15 because it should be enough for terminal based applications and keep
+ * the memory usage low for a terminal row, won't affect performance or cause terminal to
+ * lag or hang, and will keep malicious applications from causing harm. The value can be
+ * increased if ever needed for legitimate applications.
+ */
+ private static final int MAX_COMBINING_CHARACTERS_PER_COLUMN = 15;
+
+ /** The number of columns in this terminal row. */
+ private final int mColumns;
+ /** The text filling this terminal row. */
+ public char[] mText;
+ /** The number of java chars used in {@link #mText}. */
+ private short mSpaceUsed;
+ /** If this row has been line wrapped due to text output at the end of line. */
+ boolean mLineWrap;
+ /** The style bits of each cell in the row. See {@link TextStyle}. */
+ final long[] mStyle;
+ /** If this row might contain chars with width != 1, used for deactivating fast path */
+ boolean mHasNonOneWidthOrSurrogateChars;
+
+ /** Construct a blank row (containing only whitespace, ' ') with a specified style. */
+ public TerminalRow(int columns, long style) {
+ mColumns = columns;
+ mText = new char[(int) (SPARE_CAPACITY_FACTOR * columns)];
+ mStyle = new long[columns];
+ clear(style);
+ }
+
+ /** NOTE: The sourceX2 is exclusive. */
+ public void copyInterval(TerminalRow line, int sourceX1, int sourceX2, int destinationX) {
+ mHasNonOneWidthOrSurrogateChars |= line.mHasNonOneWidthOrSurrogateChars;
+ final int x1 = line.findStartOfColumn(sourceX1);
+ 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;
+ int latestNonCombiningWidth = 0;
+ for (int i = x1; i < x2; i++) {
+ char sourceChar = sourceChars[i];
+ int codePoint = Character.isHighSurrogate(sourceChar) ? Character.toCodePoint(sourceChar, sourceChars[++i]) : sourceChar;
+ if (startingFromSecondHalfOfWideChar) {
+ // Just treat copying second half of wide char as copying whitespace.
+ codePoint = ' ';
+ startingFromSecondHalfOfWideChar = false;
+ }
+ int w = WcWidth.width(codePoint);
+ if (w > 0) {
+ destinationX += latestNonCombiningWidth;
+ sourceX1 += latestNonCombiningWidth;
+ latestNonCombiningWidth = w;
+ }
+ setChar(destinationX, codePoint, line.getStyle(sourceX1));
+ }
+ }
+
+ public int getSpaceUsed() {
+ return mSpaceUsed;
+ }
+
+ /** Note that the column may end of second half of wide character. */
+ public int findStartOfColumn(int column) {
+ if (column == mColumns) return getSpaceUsed();
+
+ int currentColumn = 0;
+ int currentCharIndex = 0;
+ while (true) { // 0<2 1 < 2
+ int newCharIndex = currentCharIndex;
+ char c = mText[newCharIndex++]; // cci=1, cci=2
+ boolean isHigh = Character.isHighSurrogate(c);
+ int codePoint = isHigh ? Character.toCodePoint(c, mText[newCharIndex++]) : c;
+ int wcwidth = WcWidth.width(codePoint); // 1, 2
+ if (wcwidth > 0) {
+ currentColumn += wcwidth;
+ if (currentColumn == column) {
+ while (newCharIndex < mSpaceUsed) {
+ // Skip combining chars.
+ if (Character.isHighSurrogate(mText[newCharIndex])) {
+ if (WcWidth.width(Character.toCodePoint(mText[newCharIndex], mText[newCharIndex + 1])) <= 0) {
+ newCharIndex += 2;
+ } else {
+ break;
+ }
+ } else if (WcWidth.width(mText[newCharIndex]) <= 0) {
+ newCharIndex++;
+ } else {
+ break;
+ }
+ }
+ return newCharIndex;
+ } else if (currentColumn > column) {
+ // Wide column going past end.
+ return currentCharIndex;
+ }
+ }
+ currentCharIndex = newCharIndex;
+ }
+ }
+
+ private boolean wideDisplayCharacterStartingAt(int column) {
+ for (int currentCharIndex = 0, currentColumn = 0; currentCharIndex < mSpaceUsed; ) {
+ char c = mText[currentCharIndex++];
+ int codePoint = Character.isHighSurrogate(c) ? Character.toCodePoint(c, mText[currentCharIndex++]) : c;
+ int wcwidth = WcWidth.width(codePoint);
+ if (wcwidth > 0) {
+ if (currentColumn == column && wcwidth == 2) return true;
+ currentColumn += wcwidth;
+ if (currentColumn > column) return false;
+ }
+ }
+ return false;
+ }
+
+ public void clear(long style) {
+ Arrays.fill(mText, ' ');
+ Arrays.fill(mStyle, style);
+ mSpaceUsed = (short) mColumns;
+ mHasNonOneWidthOrSurrogateChars = false;
+ }
+
+ // https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26
+ public void setChar(int columnToSet, int codePoint, long style) {
+ 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);
+
+ // Fast path when we don't have any chars with width != 1
+ if (!mHasNonOneWidthOrSurrogateChars) {
+ if (codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT || newCodePointDisplayWidth != 1) {
+ mHasNonOneWidthOrSurrogateChars = true;
+ } else {
+ mText[columnToSet] = (char) codePoint;
+ return;
+ }
+ }
+
+ final boolean newIsCombining = newCodePointDisplayWidth <= 0;
+
+ boolean wasExtraColForWideChar = (columnToSet > 0) && wideDisplayCharacterStartingAt(columnToSet - 1);
+
+ if (newIsCombining) {
+ // When standing at second half of wide character and inserting combining:
+ if (wasExtraColForWideChar) 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);
+ // Check if we are overwriting the first half of a wide character starting at the next column:
+ boolean overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(columnToSet + 1);
+ if (overwritingWideCharInNextColumn) setChar(columnToSet + 1, ' ', style);
+ }
+
+ char[] text = mText;
+ final int oldStartOfColumnIndex = findStartOfColumn(columnToSet);
+ final int oldCodePointDisplayWidth = WcWidth.width(text, oldStartOfColumnIndex);
+
+ // Get the number of elements in the mText array this column uses now
+ int oldCharactersUsedForColumn;
+ if (columnToSet + oldCodePointDisplayWidth < mColumns) {
+ int oldEndOfColumnIndex = findStartOfColumn(columnToSet + oldCodePointDisplayWidth);
+ oldCharactersUsedForColumn = oldEndOfColumnIndex - oldStartOfColumnIndex;
+ } else {
+ // Last character.
+ oldCharactersUsedForColumn = mSpaceUsed - oldStartOfColumnIndex;
+ }
+
+ // If MAX_COMBINING_CHARACTERS_PER_COLUMN already exist in column, then ignore adding additional combining characters.
+ if (newIsCombining) {
+ int combiningCharsCount = WcWidth.zeroWidthCharsCount(mText, oldStartOfColumnIndex, oldStartOfColumnIndex + oldCharactersUsedForColumn);
+ if (combiningCharsCount >= MAX_COMBINING_CHARACTERS_PER_COLUMN)
+ return;
+ }
+
+ // Find how many chars this column will need
+ int newCharactersUsedForColumn = Character.charCount(codePoint);
+ if (newIsCombining) {
+ // Combining characters are added to the contents of the column instead of overwriting them, so that they
+ // modify the existing contents.
+ // FIXME: Unassigned characters also get width=0.
+ newCharactersUsedForColumn += oldCharactersUsedForColumn;
+ }
+
+ int oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn;
+ int newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn;
+
+ final int javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn;
+ if (javaCharDifference > 0) {
+ // Shift the rest of the line right.
+ int oldCharactersAfterColumn = mSpaceUsed - oldNextColumnIndex;
+ if (mSpaceUsed + javaCharDifference > text.length) {
+ // We need to grow the array
+ char[] newText = new char[text.length + mColumns];
+ System.arraycopy(text, 0, newText, 0, oldNextColumnIndex);
+ System.arraycopy(text, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn);
+ mText = text = newText;
+ } else {
+ System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, oldCharactersAfterColumn);
+ }
+ } else if (javaCharDifference < 0) {
+ // Shift the rest of the line left.
+ System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - oldNextColumnIndex);
+ }
+ mSpaceUsed += javaCharDifference;
+
+ // Store char. A combining character is stored at the end of the existing contents so that it modifies them:
+ //noinspection ResultOfMethodCallIgnored - since we already now how many java chars is used.
+ Character.toChars(codePoint, text, oldStartOfColumnIndex + (newIsCombining ? oldCharactersUsedForColumn : 0));
+
+ if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) {
+ // Replace second half of wide char with a space. Which mean that we actually add a ' ' java character.
+ if (mSpaceUsed + 1 > text.length) {
+ char[] newText = new char[text.length + mColumns];
+ System.arraycopy(text, 0, newText, 0, newNextColumnIndex);
+ System.arraycopy(text, newNextColumnIndex, newText, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex);
+ mText = text = newText;
+ } else {
+ System.arraycopy(text, newNextColumnIndex, text, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex);
+ }
+ text[newNextColumnIndex] = ' ';
+
+ ++mSpaceUsed;
+ } else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) {
+ if (columnToSet == mColumns - 1) {
+ throw new IllegalArgumentException("Cannot put wide character in last column");
+ } else if (columnToSet == mColumns - 2) {
+ // Truncate the line to the second part of this wide char:
+ mSpaceUsed = (short) newNextColumnIndex;
+ } else {
+ // Overwrite the contents of the next column, which mean we actually remove java characters. Due to the
+ // check at the beginning of this method we know that we are not overwriting a wide char.
+ int newNextNextColumnIndex = newNextColumnIndex + (Character.isHighSurrogate(mText[newNextColumnIndex]) ? 2 : 1);
+ int nextLen = newNextNextColumnIndex - newNextColumnIndex;
+
+ // Shift the array leftwards.
+ System.arraycopy(text, newNextNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - newNextNextColumnIndex);
+ mSpaceUsed -= nextLen;
+ }
+ }
+ }
+
+ boolean isBlank() {
+ for (int charIndex = 0, charLen = getSpaceUsed(); charIndex < charLen; charIndex++)
+ if (mText[charIndex] != ' ') return false;
+ return true;
+ }
+
+ public final long getStyle(int column) {
+ return mStyle[column];
+ }
+
+}
diff --git a/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java b/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java
new file mode 100644
index 0000000..b068be2
--- /dev/null
+++ b/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java
@@ -0,0 +1,373 @@
+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 java.io.File;
+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.
+ *
+ * The subprocess will be executed by the constructor, and when the size is made known by a call to
+ * {@link #updateSize(int, int, 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.
+ *
+ * The child process may be exited forcefully by using the {@link #finishIfRunning()} method.
+ *
+ * NOTE: The terminal session may outlive the EmulatorView, so be careful with callbacks!
+ */
+public final class TerminalSession extends TerminalOutput {
+
+ 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. */
+ TerminalSessionClient mClient;
+
+ /** 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, int, int)}.
+ */
+ private int mTerminalFileDescriptor;
+
+ /** Set by the application for user identification of session, not by terminal. */
+ public String mSessionName;
+
+ final Handler mMainThreadHandler = new MainThreadHandler();
+
+ private final String mShellPath;
+ private final String mCwd;
+ private final String[] mArgs;
+ private final String[] mEnv;
+ private final Integer mTranscriptRows;
+
+
+ private static final String LOG_TAG = "TerminalSession";
+
+ public TerminalSession(String shellPath, String cwd, String[] args, String[] env, Integer transcriptRows, TerminalSessionClient client) {
+ this.mShellPath = shellPath;
+ this.mCwd = cwd;
+ this.mArgs = args;
+ this.mEnv = env;
+ this.mTranscriptRows = transcriptRows;
+ this.mClient = client;
+ }
+
+ /**
+ * @param client The {@link TerminalSessionClient} interface implementation to allow
+ * for communication between {@link TerminalSession} and its client.
+ */
+ public void updateTerminalSessionClient(TerminalSessionClient client) {
+ mClient = client;
+
+ if (mEmulator != null)
+ mEmulator.updateTerminalSessionClient(client);
+ }
+
+ /** Inform the attached pty of the new size and reflow or initialize the emulator. */
+ public void updateSize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) {
+ if (mEmulator == null) {
+ initializeEmulator(columns, rows, cellWidthPixels, cellHeightPixels);
+ } else {
+ JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns, cellWidthPixels, cellHeightPixels);
+ mEmulator.resize(columns, rows, cellWidthPixels, cellHeightPixels);
+ }
+ }
+
+ /** 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, int cellWidthPixels, int cellHeightPixels) {
+ mEmulator = new TerminalEmulator(this, columns, rows, cellWidthPixels, cellHeightPixels, mTranscriptRows, mClient);
+
+ int[] processId = new int[1];
+ mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns, cellWidthPixels, cellHeightPixels);
+ mShellPid = processId[0];
+ mClient.setTerminalShellPid(this, mShellPid);
+
+ final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor, mClient);
+
+ 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 #mClient} that the screen has changed. */
+ protected void notifyScreenUpdate() {
+ mClient.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) {
+ Logger.logWarn(mClient, LOG_TAG, "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) {
+ mClient.onTitleChanged(this);
+ }
+
+ public synchronized boolean isRunning() {
+ return mShellPid != -1;
+ }
+
+ /** Only valid if not {@link #isRunning()}. */
+ public synchronized int getExitStatus() {
+ return mShellExitStatus;
+ }
+
+ @Override
+ public void onCopyTextToClipboard(String text) {
+ mClient.onCopyTextToClipboard(this, text);
+ }
+
+ @Override
+ public void onPasteTextFromClipboard() {
+ mClient.onPasteTextFromClipboard(this);
+ }
+
+ @Override
+ public void onBell() {
+ mClient.onBell(this);
+ }
+
+ @Override
+ public void onColorsChanged() {
+ mClient.onColorsChanged(this);
+ }
+
+ public int getPid() {
+ return mShellPid;
+ }
+
+ /** Returns the shell's working directory or null if it was unavailable. */
+ public String getCwd() {
+ if (mShellPid < 1) {
+ return null;
+ }
+ try {
+ final String cwdSymlink = String.format("/proc/%s/cwd/", mShellPid);
+ String outputPath = new File(cwdSymlink).getCanonicalPath();
+ String outputPathWithTrailingSlash = outputPath;
+ if (!outputPath.endsWith("/")) {
+ outputPathWithTrailingSlash += '/';
+ }
+ if (!cwdSymlink.equals(outputPathWithTrailingSlash)) {
+ return outputPath;
+ }
+ } catch (IOException | SecurityException e) {
+ Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Error getting current directory", e);
+ }
+ return null;
+ }
+
+ private static FileDescriptor wrapFileDescriptor(int fileDescriptor, TerminalSessionClient client) {
+ 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) {
+ Logger.logStackTraceWithMessage(client, LOG_TAG, "Error accessing FileDescriptor#descriptor private field", e);
+ System.exit(1);
+ }
+ return result;
+ }
+
+ @SuppressLint("HandlerLeak")
+ class MainThreadHandler extends Handler {
+
+ final byte[] mReceiveBuffer = new byte[4 * 1024];
+
+ @Override
+ public void handleMessage(Message msg) {
+ int bytesRead = mProcessToTerminalIOQueue.read(mReceiveBuffer, false);
+ if (bytesRead > 0) {
+ mEmulator.append(mReceiveBuffer, bytesRead);
+ notifyScreenUpdate();
+ }
+
+ if (msg.what == MSG_PROCESS_EXITED) {
+ int exitCode = (Integer) msg.obj;
+ cleanupResources(exitCode);
+
+ 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();
+
+ mClient.onSessionFinished(TerminalSession.this);
+ }
+ }
+
+ }
+
+}
diff --git a/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java b/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java
new file mode 100644
index 0000000..fbd8e55
--- /dev/null
+++ b/android/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java
@@ -0,0 +1,51 @@
+package com.termux.terminal;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * The interface for communication between {@link TerminalSession} and its client. It is used to
+ * send callbacks to the client when {@link TerminalSession} changes or for sending other
+ * back data to the client like logs.
+ */
+public interface TerminalSessionClient {
+
+ void onTextChanged(@NonNull TerminalSession changedSession);
+
+ void onTitleChanged(@NonNull TerminalSession changedSession);
+
+ void onSessionFinished(@NonNull TerminalSession finishedSession);
+
+ void onCopyTextToClipboard(@NonNull TerminalSession session, String text);
+
+ void onPasteTextFromClipboard(@Nullable TerminalSession session);
+
+ void onBell(@NonNull TerminalSession session);
+
+ void onColorsChanged(@NonNull TerminalSession session);
+
+ void onTerminalCursorStateChange(boolean state);
+
+ void setTerminalShellPid(@NonNull TerminalSession session, int pid);
+
+
+
+ Integer getTerminalCursorStyle();
+
+
+
+ void logError(String tag, String message);
+
+ void logWarn(String tag, String message);
+
+ void logInfo(String tag, String message);
+
+ void logDebug(String tag, String message);
+
+ void logVerbose(String tag, String message);
+
+ void logStackTraceWithMessage(String tag, String message, Exception e);
+
+ void logStackTrace(String tag, Exception e);
+
+}
diff --git a/android/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java b/android/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java
new file mode 100644
index 0000000..173d6ae
--- /dev/null
+++ b/android/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java
@@ -0,0 +1,90 @@
+package com.termux.terminal;
+
+/**
+ *
+ * Encodes effects, foreground and background colors into a 64 bit long, which are stored for each cell in a terminal
+ * row in {@link TerminalRow#mStyle}.
+ *
+ *
+ * The bit layout is:
+ *
+ * - 16 flags (11 currently used).
+ * - 24 for foreground color (only 9 first bits if a color index).
+ * - 24 for background color (only 9 first bits if a color index).
+ */
+public final class TextStyle {
+
+ public final static int CHARACTER_ATTRIBUTE_BOLD = 1;
+ public final static int CHARACTER_ATTRIBUTE_ITALIC = 1 << 1;
+ public final static int CHARACTER_ATTRIBUTE_UNDERLINE = 1 << 2;
+ public final static int CHARACTER_ATTRIBUTE_BLINK = 1 << 3;
+ public final static int CHARACTER_ATTRIBUTE_INVERSE = 1 << 4;
+ public final static int CHARACTER_ATTRIBUTE_INVISIBLE = 1 << 5;
+ public final static int CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 << 6;
+ /**
+ * The selective erase control functions (DECSED and DECSEL) can only erase characters defined as erasable.
+ *
+ * This bit is set if DECSCA (Select Character Protection Attribute) has been used to define the characters that
+ * come after it as erasable from the screen.
+ *
+ */
+ public final static int CHARACTER_ATTRIBUTE_PROTECTED = 1 << 7;
+ /** Dim colors. Also known as faint or half intensity. */
+ public final static int CHARACTER_ATTRIBUTE_DIM = 1 << 8;
+ /** If true (24-bit) color is used for the cell for foreground. */
+ private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 << 9;
+ /** If true (24-bit) color is used for the cell for foreground. */
+ private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND= 1 << 10;
+
+ public final static int COLOR_INDEX_FOREGROUND = 256;
+ public final static int COLOR_INDEX_BACKGROUND = 257;
+ public final static int COLOR_INDEX_CURSOR = 258;
+
+ /** The 256 standard color entries and the three special (foreground, background and cursor) ones. */
+ public final static int NUM_INDEXED_COLORS = 259;
+
+ /** Normal foreground and background colors and no effects. */
+ final static long NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0);
+
+ static long encode(int foreColor, int backColor, int effect) {
+ long result = effect & 0b111111111;
+ if ((0xff000000 & foreColor) == 0xff000000) {
+ // 24-bit color.
+ result |= CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND | ((foreColor & 0x00ffffffL) << 40L);
+ } else {
+ // Indexed color.
+ result |= (foreColor & 0b111111111L) << 40;
+ }
+ if ((0xff000000 & backColor) == 0xff000000) {
+ // 24-bit color.
+ result |= CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND | ((backColor & 0x00ffffffL) << 16L);
+ } else {
+ // Indexed color.
+ result |= (backColor & 0b111111111L) << 16L;
+ }
+
+ return result;
+ }
+
+ public static int decodeForeColor(long style) {
+ if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND) == 0) {
+ return (int) ((style >>> 40) & 0b111111111L);
+ } else {
+ return 0xff000000 | (int) ((style >>> 40) & 0x00ffffffL);
+ }
+
+ }
+
+ public static int decodeBackColor(long style) {
+ if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND) == 0) {
+ return (int) ((style >>> 16) & 0b111111111L);
+ } else {
+ return 0xff000000 | (int) ((style >>> 16) & 0x00ffffffL);
+ }
+ }
+
+ public static int decodeEffect(long style) {
+ return (int) (style & 0b11111111111);
+ }
+
+}
diff --git a/android/terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java b/android/terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java
new file mode 100644
index 0000000..d71cc27
--- /dev/null
+++ b/android/terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java
@@ -0,0 +1,566 @@
+package com.termux.terminal;
+
+/**
+ * Implementation of wcwidth(3) for Unicode 15.
+ *
+ * Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters.
+ *
+ * IMPORTANT:
+ * Must be kept in sync with the following:
+ * https://github.com/termux/wcwidth
+ * https://github.com/termux/libandroid-support
+ * https://github.com/termux/termux-packages/tree/master/packages/libandroid-support
+ */
+public final class WcWidth {
+
+ // From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py
+ // from https://github.com/jquast/wcwidth/pull/64
+ // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
+ private static final int[][] ZERO_WIDTH = {
+ {0x00300, 0x0036f}, // Combining Grave Accent ..Combining Latin Small Le
+ {0x00483, 0x00489}, // Combining Cyrillic Titlo..Combining Cyrillic Milli
+ {0x00591, 0x005bd}, // Hebrew Accent Etnahta ..Hebrew Point Meteg
+ {0x005bf, 0x005bf}, // Hebrew Point Rafe ..Hebrew Point Rafe
+ {0x005c1, 0x005c2}, // Hebrew Point Shin Dot ..Hebrew Point Sin Dot
+ {0x005c4, 0x005c5}, // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot
+ {0x005c7, 0x005c7}, // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata
+ {0x00610, 0x0061a}, // Arabic Sign Sallallahou ..Arabic Small Kasra
+ {0x0064b, 0x0065f}, // Arabic Fathatan ..Arabic Wavy Hamza Below
+ {0x00670, 0x00670}, // Arabic Letter Superscrip..Arabic Letter Superscrip
+ {0x006d6, 0x006dc}, // Arabic Small High Ligatu..Arabic Small High Seen
+ {0x006df, 0x006e4}, // Arabic Small High Rounde..Arabic Small High Madda
+ {0x006e7, 0x006e8}, // Arabic Small High Yeh ..Arabic Small High Noon
+ {0x006ea, 0x006ed}, // Arabic Empty Centre Low ..Arabic Small Low Meem
+ {0x00711, 0x00711}, // Syriac Letter Superscrip..Syriac Letter Superscrip
+ {0x00730, 0x0074a}, // Syriac Pthaha Above ..Syriac Barrekh
+ {0x007a6, 0x007b0}, // Thaana Abafili ..Thaana Sukun
+ {0x007eb, 0x007f3}, // Nko Combining Short High..Nko Combining Double Dot
+ {0x007fd, 0x007fd}, // Nko Dantayalan ..Nko Dantayalan
+ {0x00816, 0x00819}, // Samaritan Mark In ..Samaritan Mark Dagesh
+ {0x0081b, 0x00823}, // Samaritan Mark Epentheti..Samaritan Vowel Sign A
+ {0x00825, 0x00827}, // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U
+ {0x00829, 0x0082d}, // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa
+ {0x00859, 0x0085b}, // Mandaic Affrication Mark..Mandaic Gemination Mark
+ {0x00898, 0x0089f}, // Arabic Small High Word A..Arabic Half Madda Over M
+ {0x008ca, 0x008e1}, // Arabic Small High Farsi ..Arabic Small High Sign S
+ {0x008e3, 0x00902}, // Arabic Turned Damma Belo..Devanagari Sign Anusvara
+ {0x0093a, 0x0093a}, // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe
+ {0x0093c, 0x0093c}, // Devanagari Sign Nukta ..Devanagari Sign Nukta
+ {0x00941, 0x00948}, // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai
+ {0x0094d, 0x0094d}, // Devanagari Sign Virama ..Devanagari Sign Virama
+ {0x00951, 0x00957}, // Devanagari Stress Sign U..Devanagari Vowel Sign Uu
+ {0x00962, 0x00963}, // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo
+ {0x00981, 0x00981}, // Bengali Sign Candrabindu..Bengali Sign Candrabindu
+ {0x009bc, 0x009bc}, // Bengali Sign Nukta ..Bengali Sign Nukta
+ {0x009c1, 0x009c4}, // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal
+ {0x009cd, 0x009cd}, // Bengali Sign Virama ..Bengali Sign Virama
+ {0x009e2, 0x009e3}, // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal
+ {0x009fe, 0x009fe}, // Bengali Sandhi Mark ..Bengali Sandhi Mark
+ {0x00a01, 0x00a02}, // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi
+ {0x00a3c, 0x00a3c}, // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta
+ {0x00a41, 0x00a42}, // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu
+ {0x00a47, 0x00a48}, // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai
+ {0x00a4b, 0x00a4d}, // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama
+ {0x00a51, 0x00a51}, // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat
+ {0x00a70, 0x00a71}, // Gurmukhi Tippi ..Gurmukhi Addak
+ {0x00a75, 0x00a75}, // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash
+ {0x00a81, 0x00a82}, // Gujarati Sign Candrabind..Gujarati Sign Anusvara
+ {0x00abc, 0x00abc}, // Gujarati Sign Nukta ..Gujarati Sign Nukta
+ {0x00ac1, 0x00ac5}, // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand
+ {0x00ac7, 0x00ac8}, // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai
+ {0x00acd, 0x00acd}, // Gujarati Sign Virama ..Gujarati Sign Virama
+ {0x00ae2, 0x00ae3}, // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca
+ {0x00afa, 0x00aff}, // Gujarati Sign Sukun ..Gujarati Sign Two-circle
+ {0x00b01, 0x00b01}, // Oriya Sign Candrabindu ..Oriya Sign Candrabindu
+ {0x00b3c, 0x00b3c}, // Oriya Sign Nukta ..Oriya Sign Nukta
+ {0x00b3f, 0x00b3f}, // Oriya Vowel Sign I ..Oriya Vowel Sign I
+ {0x00b41, 0x00b44}, // Oriya Vowel Sign U ..Oriya Vowel Sign Vocalic
+ {0x00b4d, 0x00b4d}, // Oriya Sign Virama ..Oriya Sign Virama
+ {0x00b55, 0x00b56}, // Oriya Sign Overline ..Oriya Ai Length Mark
+ {0x00b62, 0x00b63}, // Oriya Vowel Sign Vocalic..Oriya Vowel Sign Vocalic
+ {0x00b82, 0x00b82}, // Tamil Sign Anusvara ..Tamil Sign Anusvara
+ {0x00bc0, 0x00bc0}, // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii
+ {0x00bcd, 0x00bcd}, // Tamil Sign Virama ..Tamil Sign Virama
+ {0x00c00, 0x00c00}, // Telugu Sign Combining Ca..Telugu Sign Combining Ca
+ {0x00c04, 0x00c04}, // Telugu Sign Combining An..Telugu Sign Combining An
+ {0x00c3c, 0x00c3c}, // Telugu Sign Nukta ..Telugu Sign Nukta
+ {0x00c3e, 0x00c40}, // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii
+ {0x00c46, 0x00c48}, // Telugu Vowel Sign E ..Telugu Vowel Sign Ai
+ {0x00c4a, 0x00c4d}, // Telugu Vowel Sign O ..Telugu Sign Virama
+ {0x00c55, 0x00c56}, // Telugu Length Mark ..Telugu Ai Length Mark
+ {0x00c62, 0x00c63}, // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali
+ {0x00c81, 0x00c81}, // Kannada Sign Candrabindu..Kannada Sign Candrabindu
+ {0x00cbc, 0x00cbc}, // Kannada Sign Nukta ..Kannada Sign Nukta
+ {0x00cbf, 0x00cbf}, // Kannada Vowel Sign I ..Kannada Vowel Sign I
+ {0x00cc6, 0x00cc6}, // Kannada Vowel Sign E ..Kannada Vowel Sign E
+ {0x00ccc, 0x00ccd}, // Kannada Vowel Sign Au ..Kannada Sign Virama
+ {0x00ce2, 0x00ce3}, // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal
+ {0x00d00, 0x00d01}, // Malayalam Sign Combining..Malayalam Sign Candrabin
+ {0x00d3b, 0x00d3c}, // Malayalam Sign Vertical ..Malayalam Sign Circular
+ {0x00d41, 0x00d44}, // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc
+ {0x00d4d, 0x00d4d}, // Malayalam Sign Virama ..Malayalam Sign Virama
+ {0x00d62, 0x00d63}, // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc
+ {0x00d81, 0x00d81}, // Sinhala Sign Candrabindu..Sinhala Sign Candrabindu
+ {0x00dca, 0x00dca}, // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna
+ {0x00dd2, 0x00dd4}, // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti
+ {0x00dd6, 0x00dd6}, // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga
+ {0x00e31, 0x00e31}, // Thai Character Mai Han-a..Thai Character Mai Han-a
+ {0x00e34, 0x00e3a}, // Thai Character Sara I ..Thai Character Phinthu
+ {0x00e47, 0x00e4e}, // Thai Character Maitaikhu..Thai Character Yamakkan
+ {0x00eb1, 0x00eb1}, // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan
+ {0x00eb4, 0x00ebc}, // Lao Vowel Sign I ..Lao Semivowel Sign Lo
+ {0x00ec8, 0x00ece}, // Lao Tone Mai Ek ..(nil)
+ {0x00f18, 0x00f19}, // Tibetan Astrological Sig..Tibetan Astrological Sig
+ {0x00f35, 0x00f35}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
+ {0x00f37, 0x00f37}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
+ {0x00f39, 0x00f39}, // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru
+ {0x00f71, 0x00f7e}, // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga
+ {0x00f80, 0x00f84}, // Tibetan Vowel Sign Rever..Tibetan Mark Halanta
+ {0x00f86, 0x00f87}, // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags
+ {0x00f8d, 0x00f97}, // Tibetan Subjoined Sign L..Tibetan Subjoined Letter
+ {0x00f99, 0x00fbc}, // Tibetan Subjoined Letter..Tibetan Subjoined Letter
+ {0x00fc6, 0x00fc6}, // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda
+ {0x0102d, 0x01030}, // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu
+ {0x01032, 0x01037}, // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below
+ {0x01039, 0x0103a}, // Myanmar Sign Virama ..Myanmar Sign Asat
+ {0x0103d, 0x0103e}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M
+ {0x01058, 0x01059}, // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal
+ {0x0105e, 0x01060}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M
+ {0x01071, 0x01074}, // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah
+ {0x01082, 0x01082}, // Myanmar Consonant Sign S..Myanmar Consonant Sign S
+ {0x01085, 0x01086}, // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan
+ {0x0108d, 0x0108d}, // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci
+ {0x0109d, 0x0109d}, // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton
+ {0x0135d, 0x0135f}, // Ethiopic Combining Gemin..Ethiopic Combining Gemin
+ {0x01712, 0x01714}, // Tagalog Vowel Sign I ..Tagalog Sign Virama
+ {0x01732, 0x01733}, // Hanunoo Vowel Sign I ..Hanunoo Vowel Sign U
+ {0x01752, 0x01753}, // Buhid Vowel Sign I ..Buhid Vowel Sign U
+ {0x01772, 0x01773}, // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U
+ {0x017b4, 0x017b5}, // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa
+ {0x017b7, 0x017bd}, // Khmer Vowel Sign I ..Khmer Vowel Sign Ua
+ {0x017c6, 0x017c6}, // Khmer Sign Nikahit ..Khmer Sign Nikahit
+ {0x017c9, 0x017d3}, // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat
+ {0x017dd, 0x017dd}, // Khmer Sign Atthacan ..Khmer Sign Atthacan
+ {0x0180b, 0x0180d}, // Mongolian Free Variation..Mongolian Free Variation
+ {0x0180f, 0x0180f}, // Mongolian Free Variation..Mongolian Free Variation
+ {0x01885, 0x01886}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
+ {0x018a9, 0x018a9}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
+ {0x01920, 0x01922}, // Limbu Vowel Sign A ..Limbu Vowel Sign U
+ {0x01927, 0x01928}, // Limbu Vowel Sign E ..Limbu Vowel Sign O
+ {0x01932, 0x01932}, // Limbu Small Letter Anusv..Limbu Small Letter Anusv
+ {0x01939, 0x0193b}, // Limbu Sign Mukphreng ..Limbu Sign Sa-i
+ {0x01a17, 0x01a18}, // Buginese Vowel Sign I ..Buginese Vowel Sign U
+ {0x01a1b, 0x01a1b}, // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae
+ {0x01a56, 0x01a56}, // Tai Tham Consonant Sign ..Tai Tham Consonant Sign
+ {0x01a58, 0x01a5e}, // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign
+ {0x01a60, 0x01a60}, // Tai Tham Sign Sakot ..Tai Tham Sign Sakot
+ {0x01a62, 0x01a62}, // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai
+ {0x01a65, 0x01a6c}, // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B
+ {0x01a73, 0x01a7c}, // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue
+ {0x01a7f, 0x01a7f}, // Tai Tham Combining Crypt..Tai Tham Combining Crypt
+ {0x01ab0, 0x01ace}, // Combining Doubled Circum..Combining Latin Small Le
+ {0x01b00, 0x01b03}, // Balinese Sign Ulu Ricem ..Balinese Sign Surang
+ {0x01b34, 0x01b34}, // Balinese Sign Rerekan ..Balinese Sign Rerekan
+ {0x01b36, 0x01b3a}, // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R
+ {0x01b3c, 0x01b3c}, // Balinese Vowel Sign La L..Balinese Vowel Sign La L
+ {0x01b42, 0x01b42}, // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe
+ {0x01b6b, 0x01b73}, // Balinese Musical Symbol ..Balinese Musical Symbol
+ {0x01b80, 0x01b81}, // Sundanese Sign Panyecek ..Sundanese Sign Panglayar
+ {0x01ba2, 0x01ba5}, // Sundanese Consonant Sign..Sundanese Vowel Sign Pan
+ {0x01ba8, 0x01ba9}, // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan
+ {0x01bab, 0x01bad}, // Sundanese Sign Virama ..Sundanese Consonant Sign
+ {0x01be6, 0x01be6}, // Batak Sign Tompi ..Batak Sign Tompi
+ {0x01be8, 0x01be9}, // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee
+ {0x01bed, 0x01bed}, // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O
+ {0x01bef, 0x01bf1}, // Batak Vowel Sign U For S..Batak Consonant Sign H
+ {0x01c2c, 0x01c33}, // Lepcha Vowel Sign E ..Lepcha Consonant Sign T
+ {0x01c36, 0x01c37}, // Lepcha Sign Ran ..Lepcha Sign Nukta
+ {0x01cd0, 0x01cd2}, // Vedic Tone Karshana ..Vedic Tone Prenkha
+ {0x01cd4, 0x01ce0}, // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash
+ {0x01ce2, 0x01ce8}, // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda
+ {0x01ced, 0x01ced}, // Vedic Sign Tiryak ..Vedic Sign Tiryak
+ {0x01cf4, 0x01cf4}, // Vedic Tone Candra Above ..Vedic Tone Candra Above
+ {0x01cf8, 0x01cf9}, // Vedic Tone Ring Above ..Vedic Tone Double Ring A
+ {0x01dc0, 0x01dff}, // Combining Dotted Grave A..Combining Right Arrowhea
+ {0x020d0, 0x020f0}, // Combining Left Harpoon A..Combining Asterisk Above
+ {0x02cef, 0x02cf1}, // Coptic Combining Ni Abov..Coptic Combining Spiritu
+ {0x02d7f, 0x02d7f}, // Tifinagh Consonant Joine..Tifinagh Consonant Joine
+ {0x02de0, 0x02dff}, // Combining Cyrillic Lette..Combining Cyrillic Lette
+ {0x0302a, 0x0302d}, // Ideographic Level Tone M..Ideographic Entering Ton
+ {0x03099, 0x0309a}, // Combining Katakana-hirag..Combining Katakana-hirag
+ {0x0a66f, 0x0a672}, // Combining Cyrillic Vzmet..Combining Cyrillic Thous
+ {0x0a674, 0x0a67d}, // Combining Cyrillic Lette..Combining Cyrillic Payer
+ {0x0a69e, 0x0a69f}, // Combining Cyrillic Lette..Combining Cyrillic Lette
+ {0x0a6f0, 0x0a6f1}, // Bamum Combining Mark Koq..Bamum Combining Mark Tuk
+ {0x0a802, 0x0a802}, // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva
+ {0x0a806, 0x0a806}, // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant
+ {0x0a80b, 0x0a80b}, // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva
+ {0x0a825, 0x0a826}, // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign
+ {0x0a82c, 0x0a82c}, // Syloti Nagri Sign Altern..Syloti Nagri Sign Altern
+ {0x0a8c4, 0x0a8c5}, // Saurashtra Sign Virama ..Saurashtra Sign Candrabi
+ {0x0a8e0, 0x0a8f1}, // Combining Devanagari Dig..Combining Devanagari Sig
+ {0x0a8ff, 0x0a8ff}, // Devanagari Vowel Sign Ay..Devanagari Vowel Sign Ay
+ {0x0a926, 0x0a92d}, // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop
+ {0x0a947, 0x0a951}, // Rejang Vowel Sign I ..Rejang Consonant Sign R
+ {0x0a980, 0x0a982}, // Javanese Sign Panyangga ..Javanese Sign Layar
+ {0x0a9b3, 0x0a9b3}, // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu
+ {0x0a9b6, 0x0a9b9}, // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku
+ {0x0a9bc, 0x0a9bd}, // Javanese Vowel Sign Pepe..Javanese Consonant Sign
+ {0x0a9e5, 0x0a9e5}, // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw
+ {0x0aa29, 0x0aa2e}, // Cham Vowel Sign Aa ..Cham Vowel Sign Oe
+ {0x0aa31, 0x0aa32}, // Cham Vowel Sign Au ..Cham Vowel Sign Ue
+ {0x0aa35, 0x0aa36}, // Cham Consonant Sign La ..Cham Consonant Sign Wa
+ {0x0aa43, 0x0aa43}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina
+ {0x0aa4c, 0x0aa4c}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina
+ {0x0aa7c, 0x0aa7c}, // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T
+ {0x0aab0, 0x0aab0}, // Tai Viet Mai Kang ..Tai Viet Mai Kang
+ {0x0aab2, 0x0aab4}, // Tai Viet Vowel I ..Tai Viet Vowel U
+ {0x0aab7, 0x0aab8}, // Tai Viet Mai Khit ..Tai Viet Vowel Ia
+ {0x0aabe, 0x0aabf}, // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek
+ {0x0aac1, 0x0aac1}, // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho
+ {0x0aaec, 0x0aaed}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
+ {0x0aaf6, 0x0aaf6}, // Meetei Mayek Virama ..Meetei Mayek Virama
+ {0x0abe5, 0x0abe5}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
+ {0x0abe8, 0x0abe8}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
+ {0x0abed, 0x0abed}, // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek
+ {0x0fb1e, 0x0fb1e}, // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani
+ {0x0fe00, 0x0fe0f}, // Variation Selector-1 ..Variation Selector-16
+ {0x0fe20, 0x0fe2f}, // Combining Ligature Left ..Combining Cyrillic Titlo
+ {0x101fd, 0x101fd}, // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi
+ {0x102e0, 0x102e0}, // Coptic Epact Thousands M..Coptic Epact Thousands M
+ {0x10376, 0x1037a}, // Combining Old Permic Let..Combining Old Permic Let
+ {0x10a01, 0x10a03}, // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo
+ {0x10a05, 0x10a06}, // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O
+ {0x10a0c, 0x10a0f}, // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga
+ {0x10a38, 0x10a3a}, // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo
+ {0x10a3f, 0x10a3f}, // Kharoshthi Virama ..Kharoshthi Virama
+ {0x10ae5, 0x10ae6}, // Manichaean Abbreviation ..Manichaean Abbreviation
+ {0x10d24, 0x10d27}, // Hanifi Rohingya Sign Har..Hanifi Rohingya Sign Tas
+ {0x10eab, 0x10eac}, // Yezidi Combining Hamza M..Yezidi Combining Madda M
+ {0x10efd, 0x10eff}, // (nil) ..(nil)
+ {0x10f46, 0x10f50}, // Sogdian Combining Dot Be..Sogdian Combining Stroke
+ {0x10f82, 0x10f85}, // Old Uyghur Combining Dot..Old Uyghur Combining Two
+ {0x11001, 0x11001}, // Brahmi Sign Anusvara ..Brahmi Sign Anusvara
+ {0x11038, 0x11046}, // Brahmi Vowel Sign Aa ..Brahmi Virama
+ {0x11070, 0x11070}, // Brahmi Sign Old Tamil Vi..Brahmi Sign Old Tamil Vi
+ {0x11073, 0x11074}, // Brahmi Vowel Sign Old Ta..Brahmi Vowel Sign Old Ta
+ {0x1107f, 0x11081}, // Brahmi Number Joiner ..Kaithi Sign Anusvara
+ {0x110b3, 0x110b6}, // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai
+ {0x110b9, 0x110ba}, // Kaithi Sign Virama ..Kaithi Sign Nukta
+ {0x110c2, 0x110c2}, // Kaithi Vowel Sign Vocali..Kaithi Vowel Sign Vocali
+ {0x11100, 0x11102}, // Chakma Sign Candrabindu ..Chakma Sign Visarga
+ {0x11127, 0x1112b}, // Chakma Vowel Sign A ..Chakma Vowel Sign Uu
+ {0x1112d, 0x11134}, // Chakma Vowel Sign Ai ..Chakma Maayyaa
+ {0x11173, 0x11173}, // Mahajani Sign Nukta ..Mahajani Sign Nukta
+ {0x11180, 0x11181}, // Sharada Sign Candrabindu..Sharada Sign Anusvara
+ {0x111b6, 0x111be}, // Sharada Vowel Sign U ..Sharada Vowel Sign O
+ {0x111c9, 0x111cc}, // Sharada Sandhi Mark ..Sharada Extra Short Vowe
+ {0x111cf, 0x111cf}, // Sharada Sign Inverted Ca..Sharada Sign Inverted Ca
+ {0x1122f, 0x11231}, // Khojki Vowel Sign U ..Khojki Vowel Sign Ai
+ {0x11234, 0x11234}, // Khojki Sign Anusvara ..Khojki Sign Anusvara
+ {0x11236, 0x11237}, // Khojki Sign Nukta ..Khojki Sign Shadda
+ {0x1123e, 0x1123e}, // Khojki Sign Sukun ..Khojki Sign Sukun
+ {0x11241, 0x11241}, // (nil) ..(nil)
+ {0x112df, 0x112df}, // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara
+ {0x112e3, 0x112ea}, // Khudawadi Vowel Sign U ..Khudawadi Sign Virama
+ {0x11300, 0x11301}, // Grantha Sign Combining A..Grantha Sign Candrabindu
+ {0x1133b, 0x1133c}, // Combining Bindu Below ..Grantha Sign Nukta
+ {0x11340, 0x11340}, // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii
+ {0x11366, 0x1136c}, // Combining Grantha Digit ..Combining Grantha Digit
+ {0x11370, 0x11374}, // Combining Grantha Letter..Combining Grantha Letter
+ {0x11438, 0x1143f}, // Newa Vowel Sign U ..Newa Vowel Sign Ai
+ {0x11442, 0x11444}, // Newa Sign Virama ..Newa Sign Anusvara
+ {0x11446, 0x11446}, // Newa Sign Nukta ..Newa Sign Nukta
+ {0x1145e, 0x1145e}, // Newa Sandhi Mark ..Newa Sandhi Mark
+ {0x114b3, 0x114b8}, // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal
+ {0x114ba, 0x114ba}, // Tirhuta Vowel Sign Short..Tirhuta Vowel Sign Short
+ {0x114bf, 0x114c0}, // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara
+ {0x114c2, 0x114c3}, // Tirhuta Sign Virama ..Tirhuta Sign Nukta
+ {0x115b2, 0x115b5}, // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal
+ {0x115bc, 0x115bd}, // Siddham Sign Candrabindu..Siddham Sign Anusvara
+ {0x115bf, 0x115c0}, // Siddham Sign Virama ..Siddham Sign Nukta
+ {0x115dc, 0x115dd}, // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter
+ {0x11633, 0x1163a}, // Modi Vowel Sign U ..Modi Vowel Sign Ai
+ {0x1163d, 0x1163d}, // Modi Sign Anusvara ..Modi Sign Anusvara
+ {0x1163f, 0x11640}, // Modi Sign Virama ..Modi Sign Ardhacandra
+ {0x116ab, 0x116ab}, // Takri Sign Anusvara ..Takri Sign Anusvara
+ {0x116ad, 0x116ad}, // Takri Vowel Sign Aa ..Takri Vowel Sign Aa
+ {0x116b0, 0x116b5}, // Takri Vowel Sign U ..Takri Vowel Sign Au
+ {0x116b7, 0x116b7}, // Takri Sign Nukta ..Takri Sign Nukta
+ {0x1171d, 0x1171f}, // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi
+ {0x11722, 0x11725}, // Ahom Vowel Sign I ..Ahom Vowel Sign Uu
+ {0x11727, 0x1172b}, // Ahom Vowel Sign Aw ..Ahom Sign Killer
+ {0x1182f, 0x11837}, // Dogra Vowel Sign U ..Dogra Sign Anusvara
+ {0x11839, 0x1183a}, // Dogra Sign Virama ..Dogra Sign Nukta
+ {0x1193b, 0x1193c}, // Dives Akuru Sign Anusvar..Dives Akuru Sign Candrab
+ {0x1193e, 0x1193e}, // Dives Akuru Virama ..Dives Akuru Virama
+ {0x11943, 0x11943}, // Dives Akuru Sign Nukta ..Dives Akuru Sign Nukta
+ {0x119d4, 0x119d7}, // Nandinagari Vowel Sign U..Nandinagari Vowel Sign V
+ {0x119da, 0x119db}, // Nandinagari Vowel Sign E..Nandinagari Vowel Sign A
+ {0x119e0, 0x119e0}, // Nandinagari Sign Virama ..Nandinagari Sign Virama
+ {0x11a01, 0x11a0a}, // Zanabazar Square Vowel S..Zanabazar Square Vowel L
+ {0x11a33, 0x11a38}, // Zanabazar Square Final C..Zanabazar Square Sign An
+ {0x11a3b, 0x11a3e}, // Zanabazar Square Cluster..Zanabazar Square Cluster
+ {0x11a47, 0x11a47}, // Zanabazar Square Subjoin..Zanabazar Square Subjoin
+ {0x11a51, 0x11a56}, // Soyombo Vowel Sign I ..Soyombo Vowel Sign Oe
+ {0x11a59, 0x11a5b}, // Soyombo Vowel Sign Vocal..Soyombo Vowel Length Mar
+ {0x11a8a, 0x11a96}, // Soyombo Final Consonant ..Soyombo Sign Anusvara
+ {0x11a98, 0x11a99}, // Soyombo Gemination Mark ..Soyombo Subjoiner
+ {0x11c30, 0x11c36}, // Bhaiksuki Vowel Sign I ..Bhaiksuki Vowel Sign Voc
+ {0x11c38, 0x11c3d}, // Bhaiksuki Vowel Sign E ..Bhaiksuki Sign Anusvara
+ {0x11c3f, 0x11c3f}, // Bhaiksuki Sign Virama ..Bhaiksuki Sign Virama
+ {0x11c92, 0x11ca7}, // Marchen Subjoined Letter..Marchen Subjoined Letter
+ {0x11caa, 0x11cb0}, // Marchen Subjoined Letter..Marchen Vowel Sign Aa
+ {0x11cb2, 0x11cb3}, // Marchen Vowel Sign U ..Marchen Vowel Sign E
+ {0x11cb5, 0x11cb6}, // Marchen Sign Anusvara ..Marchen Sign Candrabindu
+ {0x11d31, 0x11d36}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
+ {0x11d3a, 0x11d3a}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
+ {0x11d3c, 0x11d3d}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
+ {0x11d3f, 0x11d45}, // Masaram Gondi Vowel Sign..Masaram Gondi Virama
+ {0x11d47, 0x11d47}, // Masaram Gondi Ra-kara ..Masaram Gondi Ra-kara
+ {0x11d90, 0x11d91}, // Gunjala Gondi Vowel Sign..Gunjala Gondi Vowel Sign
+ {0x11d95, 0x11d95}, // Gunjala Gondi Sign Anusv..Gunjala Gondi Sign Anusv
+ {0x11d97, 0x11d97}, // Gunjala Gondi Virama ..Gunjala Gondi Virama
+ {0x11ef3, 0x11ef4}, // Makasar Vowel Sign I ..Makasar Vowel Sign U
+ {0x11f00, 0x11f01}, // (nil) ..(nil)
+ {0x11f36, 0x11f3a}, // (nil) ..(nil)
+ {0x11f40, 0x11f40}, // (nil) ..(nil)
+ {0x11f42, 0x11f42}, // (nil) ..(nil)
+ {0x13440, 0x13440}, // (nil) ..(nil)
+ {0x13447, 0x13455}, // (nil) ..(nil)
+ {0x16af0, 0x16af4}, // Bassa Vah Combining High..Bassa Vah Combining High
+ {0x16b30, 0x16b36}, // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta
+ {0x16f4f, 0x16f4f}, // Miao Sign Consonant Modi..Miao Sign Consonant Modi
+ {0x16f8f, 0x16f92}, // Miao Tone Right ..Miao Tone Below
+ {0x16fe4, 0x16fe4}, // Khitan Small Script Fill..Khitan Small Script Fill
+ {0x1bc9d, 0x1bc9e}, // Duployan Thick Letter Se..Duployan Double Mark
+ {0x1cf00, 0x1cf2d}, // Znamenny Combining Mark ..Znamenny Combining Mark
+ {0x1cf30, 0x1cf46}, // Znamenny Combining Tonal..Znamenny Priznak Modifie
+ {0x1d167, 0x1d169}, // Musical Symbol Combining..Musical Symbol Combining
+ {0x1d17b, 0x1d182}, // Musical Symbol Combining..Musical Symbol Combining
+ {0x1d185, 0x1d18b}, // Musical Symbol Combining..Musical Symbol Combining
+ {0x1d1aa, 0x1d1ad}, // Musical Symbol Combining..Musical Symbol Combining
+ {0x1d242, 0x1d244}, // Combining Greek Musical ..Combining Greek Musical
+ {0x1da00, 0x1da36}, // Signwriting Head Rim ..Signwriting Air Sucking
+ {0x1da3b, 0x1da6c}, // Signwriting Mouth Closed..Signwriting Excitement
+ {0x1da75, 0x1da75}, // Signwriting Upper Body T..Signwriting Upper Body T
+ {0x1da84, 0x1da84}, // Signwriting Location Hea..Signwriting Location Hea
+ {0x1da9b, 0x1da9f}, // Signwriting Fill Modifie..Signwriting Fill Modifie
+ {0x1daa1, 0x1daaf}, // Signwriting Rotation Mod..Signwriting Rotation Mod
+ {0x1e000, 0x1e006}, // Combining Glagolitic Let..Combining Glagolitic Let
+ {0x1e008, 0x1e018}, // Combining Glagolitic Let..Combining Glagolitic Let
+ {0x1e01b, 0x1e021}, // Combining Glagolitic Let..Combining Glagolitic Let
+ {0x1e023, 0x1e024}, // Combining Glagolitic Let..Combining Glagolitic Let
+ {0x1e026, 0x1e02a}, // Combining Glagolitic Let..Combining Glagolitic Let
+ {0x1e08f, 0x1e08f}, // (nil) ..(nil)
+ {0x1e130, 0x1e136}, // Nyiakeng Puachue Hmong T..Nyiakeng Puachue Hmong T
+ {0x1e2ae, 0x1e2ae}, // Toto Sign Rising Tone ..Toto Sign Rising Tone
+ {0x1e2ec, 0x1e2ef}, // Wancho Tone Tup ..Wancho Tone Koini
+ {0x1e4ec, 0x1e4ef}, // (nil) ..(nil)
+ {0x1e8d0, 0x1e8d6}, // Mende Kikakui Combining ..Mende Kikakui Combining
+ {0x1e944, 0x1e94a}, // Adlam Alif Lengthener ..Adlam Nukta
+ {0xe0100, 0xe01ef}, // Variation Selector-17 ..Variation Selector-256
+ };
+
+ // https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py
+ // from https://github.com/jquast/wcwidth/pull/64
+ // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
+ private static final int[][] WIDE_EASTASIAN = {
+ {0x01100, 0x0115f}, // Hangul Choseong Kiyeok ..Hangul Choseong Filler
+ {0x0231a, 0x0231b}, // Watch ..Hourglass
+ {0x02329, 0x0232a}, // Left-pointing Angle Brac..Right-pointing Angle Bra
+ {0x023e9, 0x023ec}, // Black Right-pointing Dou..Black Down-pointing Doub
+ {0x023f0, 0x023f0}, // Alarm Clock ..Alarm Clock
+ {0x023f3, 0x023f3}, // Hourglass With Flowing S..Hourglass With Flowing S
+ {0x025fd, 0x025fe}, // White Medium Small Squar..Black Medium Small Squar
+ {0x02614, 0x02615}, // Umbrella With Rain Drops..Hot Beverage
+ {0x02648, 0x02653}, // Aries ..Pisces
+ {0x0267f, 0x0267f}, // Wheelchair Symbol ..Wheelchair Symbol
+ {0x02693, 0x02693}, // Anchor ..Anchor
+ {0x026a1, 0x026a1}, // High Voltage Sign ..High Voltage Sign
+ {0x026aa, 0x026ab}, // Medium White Circle ..Medium Black Circle
+ {0x026bd, 0x026be}, // Soccer Ball ..Baseball
+ {0x026c4, 0x026c5}, // Snowman Without Snow ..Sun Behind Cloud
+ {0x026ce, 0x026ce}, // Ophiuchus ..Ophiuchus
+ {0x026d4, 0x026d4}, // No Entry ..No Entry
+ {0x026ea, 0x026ea}, // Church ..Church
+ {0x026f2, 0x026f3}, // Fountain ..Flag In Hole
+ {0x026f5, 0x026f5}, // Sailboat ..Sailboat
+ {0x026fa, 0x026fa}, // Tent ..Tent
+ {0x026fd, 0x026fd}, // Fuel Pump ..Fuel Pump
+ {0x02705, 0x02705}, // White Heavy Check Mark ..White Heavy Check Mark
+ {0x0270a, 0x0270b}, // Raised Fist ..Raised Hand
+ {0x02728, 0x02728}, // Sparkles ..Sparkles
+ {0x0274c, 0x0274c}, // Cross Mark ..Cross Mark
+ {0x0274e, 0x0274e}, // Negative Squared Cross M..Negative Squared Cross M
+ {0x02753, 0x02755}, // Black Question Mark Orna..White Exclamation Mark O
+ {0x02757, 0x02757}, // Heavy Exclamation Mark S..Heavy Exclamation Mark S
+ {0x02795, 0x02797}, // Heavy Plus Sign ..Heavy Division Sign
+ {0x027b0, 0x027b0}, // Curly Loop ..Curly Loop
+ {0x027bf, 0x027bf}, // Double Curly Loop ..Double Curly Loop
+ {0x02b1b, 0x02b1c}, // Black Large Square ..White Large Square
+ {0x02b50, 0x02b50}, // White Medium Star ..White Medium Star
+ {0x02b55, 0x02b55}, // Heavy Large Circle ..Heavy Large Circle
+ {0x02e80, 0x02e99}, // Cjk Radical Repeat ..Cjk Radical Rap
+ {0x02e9b, 0x02ef3}, // Cjk Radical Choke ..Cjk Radical C-simplified
+ {0x02f00, 0x02fd5}, // Kangxi Radical One ..Kangxi Radical Flute
+ {0x02ff0, 0x02ffb}, // Ideographic Description ..Ideographic Description
+ {0x03000, 0x0303e}, // Ideographic Space ..Ideographic Variation In
+ {0x03041, 0x03096}, // Hiragana Letter Small A ..Hiragana Letter Small Ke
+ {0x03099, 0x030ff}, // Combining Katakana-hirag..Katakana Digraph Koto
+ {0x03105, 0x0312f}, // Bopomofo Letter B ..Bopomofo Letter Nn
+ {0x03131, 0x0318e}, // Hangul Letter Kiyeok ..Hangul Letter Araeae
+ {0x03190, 0x031e3}, // Ideographic Annotation L..Cjk Stroke Q
+ {0x031f0, 0x0321e}, // Katakana Letter Small Ku..Parenthesized Korean Cha
+ {0x03220, 0x03247}, // Parenthesized Ideograph ..Circled Ideograph Koto
+ {0x03250, 0x04dbf}, // Partnership Sign ..Cjk Unified Ideograph-4d
+ {0x04e00, 0x0a48c}, // Cjk Unified Ideograph-4e..Yi Syllable Yyr
+ {0x0a490, 0x0a4c6}, // Yi Radical Qot ..Yi Radical Ke
+ {0x0a960, 0x0a97c}, // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo
+ {0x0ac00, 0x0d7a3}, // Hangul Syllable Ga ..Hangul Syllable Hih
+ {0x0f900, 0x0faff}, // Cjk Compatibility Ideogr..(nil)
+ {0x0fe10, 0x0fe19}, // Presentation Form For Ve..Presentation Form For Ve
+ {0x0fe30, 0x0fe52}, // Presentation Form For Ve..Small Full Stop
+ {0x0fe54, 0x0fe66}, // Small Semicolon ..Small Equals Sign
+ {0x0fe68, 0x0fe6b}, // Small Reverse Solidus ..Small Commercial At
+ {0x0ff01, 0x0ff60}, // Fullwidth Exclamation Ma..Fullwidth Right White Pa
+ {0x0ffe0, 0x0ffe6}, // Fullwidth Cent Sign ..Fullwidth Won Sign
+ {0x16fe0, 0x16fe4}, // Tangut Iteration Mark ..Khitan Small Script Fill
+ {0x16ff0, 0x16ff1}, // Vietnamese Alternate Rea..Vietnamese Alternate Rea
+ {0x17000, 0x187f7}, // (nil) ..(nil)
+ {0x18800, 0x18cd5}, // Tangut Component-001 ..Khitan Small Script Char
+ {0x18d00, 0x18d08}, // (nil) ..(nil)
+ {0x1aff0, 0x1aff3}, // Katakana Letter Minnan T..Katakana Letter Minnan T
+ {0x1aff5, 0x1affb}, // Katakana Letter Minnan T..Katakana Letter Minnan N
+ {0x1affd, 0x1affe}, // Katakana Letter Minnan N..Katakana Letter Minnan N
+ {0x1b000, 0x1b122}, // Katakana Letter Archaic ..Katakana Letter Archaic
+ {0x1b132, 0x1b132}, // (nil) ..(nil)
+ {0x1b150, 0x1b152}, // Hiragana Letter Small Wi..Hiragana Letter Small Wo
+ {0x1b155, 0x1b155}, // (nil) ..(nil)
+ {0x1b164, 0x1b167}, // Katakana Letter Small Wi..Katakana Letter Small N
+ {0x1b170, 0x1b2fb}, // Nushu Character-1b170 ..Nushu Character-1b2fb
+ {0x1f004, 0x1f004}, // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon
+ {0x1f0cf, 0x1f0cf}, // Playing Card Black Joker..Playing Card Black Joker
+ {0x1f18e, 0x1f18e}, // Negative Squared Ab ..Negative Squared Ab
+ {0x1f191, 0x1f19a}, // Squared Cl ..Squared Vs
+ {0x1f200, 0x1f202}, // Square Hiragana Hoka ..Squared Katakana Sa
+ {0x1f210, 0x1f23b}, // Squared Cjk Unified Ideo..Squared Cjk Unified Ideo
+ {0x1f240, 0x1f248}, // Tortoise Shell Bracketed..Tortoise Shell Bracketed
+ {0x1f250, 0x1f251}, // Circled Ideograph Advant..Circled Ideograph Accept
+ {0x1f260, 0x1f265}, // Rounded Symbol For Fu ..Rounded Symbol For Cai
+ {0x1f300, 0x1f320}, // Cyclone ..Shooting Star
+ {0x1f32d, 0x1f335}, // Hot Dog ..Cactus
+ {0x1f337, 0x1f37c}, // Tulip ..Baby Bottle
+ {0x1f37e, 0x1f393}, // Bottle With Popping Cork..Graduation Cap
+ {0x1f3a0, 0x1f3ca}, // Carousel Horse ..Swimmer
+ {0x1f3cf, 0x1f3d3}, // Cricket Bat And Ball ..Table Tennis Paddle And
+ {0x1f3e0, 0x1f3f0}, // House Building ..European Castle
+ {0x1f3f4, 0x1f3f4}, // Waving Black Flag ..Waving Black Flag
+ {0x1f3f8, 0x1f43e}, // Badminton Racquet And Sh..Paw Prints
+ {0x1f440, 0x1f440}, // Eyes ..Eyes
+ {0x1f442, 0x1f4fc}, // Ear ..Videocassette
+ {0x1f4ff, 0x1f53d}, // Prayer Beads ..Down-pointing Small Red
+ {0x1f54b, 0x1f54e}, // Kaaba ..Menorah With Nine Branch
+ {0x1f550, 0x1f567}, // Clock Face One Oclock ..Clock Face Twelve-thirty
+ {0x1f57a, 0x1f57a}, // Man Dancing ..Man Dancing
+ {0x1f595, 0x1f596}, // Reversed Hand With Middl..Raised Hand With Part Be
+ {0x1f5a4, 0x1f5a4}, // Black Heart ..Black Heart
+ {0x1f5fb, 0x1f64f}, // Mount Fuji ..Person With Folded Hands
+ {0x1f680, 0x1f6c5}, // Rocket ..Left Luggage
+ {0x1f6cc, 0x1f6cc}, // Sleeping Accommodation ..Sleeping Accommodation
+ {0x1f6d0, 0x1f6d2}, // Place Of Worship ..Shopping Trolley
+ {0x1f6d5, 0x1f6d7}, // Hindu Temple ..Elevator
+ {0x1f6dc, 0x1f6df}, // (nil) ..Ring Buoy
+ {0x1f6eb, 0x1f6ec}, // Airplane Departure ..Airplane Arriving
+ {0x1f6f4, 0x1f6fc}, // Scooter ..Roller Skate
+ {0x1f7e0, 0x1f7eb}, // Large Orange Circle ..Large Brown Square
+ {0x1f7f0, 0x1f7f0}, // Heavy Equals Sign ..Heavy Equals Sign
+ {0x1f90c, 0x1f93a}, // Pinched Fingers ..Fencer
+ {0x1f93c, 0x1f945}, // Wrestlers ..Goal Net
+ {0x1f947, 0x1f9ff}, // First Place Medal ..Nazar Amulet
+ {0x1fa70, 0x1fa7c}, // Ballet Shoes ..Crutch
+ {0x1fa80, 0x1fa88}, // Yo-yo ..(nil)
+ {0x1fa90, 0x1fabd}, // Ringed Planet ..(nil)
+ {0x1fabf, 0x1fac5}, // (nil) ..Person With Crown
+ {0x1face, 0x1fadb}, // (nil) ..(nil)
+ {0x1fae0, 0x1fae8}, // Melting Face ..(nil)
+ {0x1faf0, 0x1faf8}, // Hand With Index Finger A..(nil)
+ {0x20000, 0x2fffd}, // Cjk Unified Ideograph-20..(nil)
+ {0x30000, 0x3fffd}, // Cjk Unified Ideograph-30..(nil)
+ };
+
+
+ private static boolean intable(int[][] table, int c) {
+ // First quick check f|| Latin1 etc. characters.
+ if (c < table[0][0]) return false;
+
+ // Binary search in table.
+ int bot = 0;
+ int top = table.length - 1; // (int)(size / sizeof(struct interval) - 1);
+ while (top >= bot) {
+ int mid = (bot + top) / 2;
+ if (table[mid][1] < c) {
+ bot = mid + 1;
+ } else if (table[mid][0] > c) {
+ top = mid - 1;
+ } else {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Return the terminal display width of a code point: 0, 1 || 2. */
+ public static int width(int ucs) {
+ if (ucs == 0 ||
+ ucs == 0x034F ||
+ (0x200B <= ucs && ucs <= 0x200F) ||
+ ucs == 0x2028 ||
+ ucs == 0x2029 ||
+ (0x202A <= ucs && ucs <= 0x202E) ||
+ (0x2060 <= ucs && ucs <= 0x2063)) {
+ return 0;
+ }
+
+ // C0/C1 control characters
+ // Termux change: Return 0 instead of -1.
+ if (ucs < 32 || (0x07F <= ucs && ucs < 0x0A0)) return 0;
+
+ // combining characters with zero width
+ if (intable(ZERO_WIDTH, ucs)) return 0;
+
+ return intable(WIDE_EASTASIAN, ucs) ? 2 : 1;
+ }
+
+ /** The width at an index position in a java char array. */
+ public static int width(char[] chars, int index) {
+ char c = chars[index];
+ return Character.isHighSurrogate(c) ? width(Character.toCodePoint(c, chars[index + 1])) : width(c);
+ }
+
+ /**
+ * The zero width characters count like combining characters in the `chars` array from start
+ * index to end index (exclusive).
+ */
+ public static int zeroWidthCharsCount(char[] chars, int start, int end) {
+ if (start < 0 || start >= chars.length)
+ return 0;
+
+ int count = 0;
+ for (int i = start; i < end && i < chars.length;) {
+ if (Character.isHighSurrogate(chars[i])) {
+ if (width(Character.toCodePoint(chars[i], chars[i + 1])) <= 0) {
+ count++;
+ }
+ i += 2;
+ } else {
+ if (width(chars[i]) <= 0) {
+ count++;
+ }
+ i++;
+ }
+ }
+ return count;
+ }
+
+}
diff --git a/android/terminal-emulator/src/main/jni/Android.mk b/android/terminal-emulator/src/main/jni/Android.mk
new file mode 100644
index 0000000..6c6f8b2
--- /dev/null
+++ b/android/terminal-emulator/src/main/jni/Android.mk
@@ -0,0 +1,5 @@
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+LOCAL_MODULE:= libtermux
+LOCAL_SRC_FILES:= termux.c
+include $(BUILD_SHARED_LIBRARY)
diff --git a/android/terminal-emulator/src/main/jni/termux.c b/android/terminal-emulator/src/main/jni/termux.c
new file mode 100644
index 0000000..8cd5e78
--- /dev/null
+++ b/android/terminal-emulator/src/main/jni/termux.c
@@ -0,0 +1,218 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define TERMUX_UNUSED(x) x __attribute__((__unused__))
+#ifdef __APPLE__
+# define LACKS_PTSNAME_R
+#endif
+
+static int throw_runtime_exception(JNIEnv* env, char const* message)
+{
+ jclass exClass = (*env)->FindClass(env, "java/lang/RuntimeException");
+ (*env)->ThrowNew(env, exClass, message);
+ return -1;
+}
+
+static int create_subprocess(JNIEnv* env,
+ char const* cmd,
+ char const* cwd,
+ char* const argv[],
+ char** envp,
+ int* pProcessId,
+ jint rows,
+ jint columns,
+ jint cell_width,
+ jint cell_height)
+{
+ int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC);
+ if (ptm < 0) return throw_runtime_exception(env, "Cannot open /dev/ptmx");
+
+#ifdef LACKS_PTSNAME_R
+ char* devname;
+#else
+ char devname[64];
+#endif
+ if (grantpt(ptm) || unlockpt(ptm) ||
+#ifdef LACKS_PTSNAME_R
+ (devname = ptsname(ptm)) == NULL
+#else
+ ptsname_r(ptm, devname, sizeof(devname))
+#endif
+ ) {
+ return throw_runtime_exception(env, "Cannot grantpt()/unlockpt()/ptsname_r() on /dev/ptmx");
+ }
+
+ // Enable UTF-8 mode and disable flow control to prevent Ctrl+S from locking up the display.
+ struct termios tios;
+ tcgetattr(ptm, &tios);
+ tios.c_iflag |= IUTF8;
+ tios.c_iflag &= ~(IXON | IXOFF);
+ tcsetattr(ptm, TCSANOW, &tios);
+
+ /** Set initial winsize. */
+ struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) columns, .ws_xpixel = (unsigned short) (columns * cell_width), .ws_ypixel = (unsigned short) (rows * cell_height)};
+ ioctl(ptm, TIOCSWINSZ, &sz);
+
+ pid_t pid = fork();
+ if (pid < 0) {
+ return throw_runtime_exception(env, "Fork failed");
+ } else if (pid > 0) {
+ *pProcessId = (int) pid;
+ return ptm;
+ } else {
+ // Clear signals which the Android java process may have blocked:
+ sigset_t signals_to_unblock;
+ sigfillset(&signals_to_unblock);
+ sigprocmask(SIG_UNBLOCK, &signals_to_unblock, 0);
+
+ close(ptm);
+ setsid();
+
+ int pts = open(devname, O_RDWR);
+ if (pts < 0) exit(-1);
+
+ dup2(pts, 0);
+ dup2(pts, 1);
+ dup2(pts, 2);
+
+ DIR* self_dir = opendir("/proc/self/fd");
+ if (self_dir != NULL) {
+ int self_dir_fd = dirfd(self_dir);
+ struct dirent* entry;
+ while ((entry = readdir(self_dir)) != NULL) {
+ int fd = atoi(entry->d_name);
+ if (fd > 2 && fd != self_dir_fd) close(fd);
+ }
+ closedir(self_dir);
+ }
+
+ clearenv();
+ if (envp) for (; *envp; ++envp) putenv(*envp);
+
+ if (chdir(cwd) != 0) {
+ char* error_message;
+ // No need to free asprintf()-allocated memory since doing execvp() or exit() below.
+ if (asprintf(&error_message, "chdir(\"%s\")", cwd) == -1) error_message = "chdir()";
+ perror(error_message);
+ fflush(stderr);
+ }
+ execvp(cmd, argv);
+ // Show terminal output about failing exec() call:
+ char* error_message;
+ if (asprintf(&error_message, "exec(\"%s\")", cmd) == -1) error_message = "exec()";
+ perror(error_message);
+ _exit(1);
+ }
+}
+
+JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
+ JNIEnv* env,
+ jclass TERMUX_UNUSED(clazz),
+ jstring cmd,
+ jstring cwd,
+ jobjectArray args,
+ jobjectArray envVars,
+ jintArray processIdArray,
+ jint rows,
+ jint columns,
+ jint cell_width,
+ jint cell_height)
+{
+ jsize size = args ? (*env)->GetArrayLength(env, args) : 0;
+ char** argv = NULL;
+ if (size > 0) {
+ argv = (char**) malloc((size + 1) * sizeof(char*));
+ if (!argv) return throw_runtime_exception(env, "Couldn't allocate argv array");
+ for (int i = 0; i < size; ++i) {
+ jstring arg_java_string = (jstring) (*env)->GetObjectArrayElement(env, args, i);
+ char const* arg_utf8 = (*env)->GetStringUTFChars(env, arg_java_string, NULL);
+ if (!arg_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for argv");
+ argv[i] = strdup(arg_utf8);
+ (*env)->ReleaseStringUTFChars(env, arg_java_string, arg_utf8);
+ }
+ argv[size] = NULL;
+ }
+
+ size = envVars ? (*env)->GetArrayLength(env, envVars) : 0;
+ char** envp = NULL;
+ if (size > 0) {
+ envp = (char**) malloc((size + 1) * sizeof(char *));
+ if (!envp) return throw_runtime_exception(env, "malloc() for envp array failed");
+ for (int i = 0; i < size; ++i) {
+ jstring env_java_string = (jstring) (*env)->GetObjectArrayElement(env, envVars, i);
+ char const* env_utf8 = (*env)->GetStringUTFChars(env, env_java_string, 0);
+ if (!env_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for env");
+ envp[i] = strdup(env_utf8);
+ (*env)->ReleaseStringUTFChars(env, env_java_string, env_utf8);
+ }
+ envp[size] = NULL;
+ }
+
+ int procId = 0;
+ char const* cmd_cwd = (*env)->GetStringUTFChars(env, cwd, NULL);
+ char const* cmd_utf8 = (*env)->GetStringUTFChars(env, cmd, NULL);
+ int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns, cell_width, cell_height);
+ (*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8);
+ (*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd);
+
+ if (argv) {
+ for (char** tmp = argv; *tmp; ++tmp) free(*tmp);
+ free(argv);
+ }
+ if (envp) {
+ for (char** tmp = envp; *tmp; ++tmp) free(*tmp);
+ free(envp);
+ }
+
+ int* pProcId = (int*) (*env)->GetPrimitiveArrayCritical(env, processIdArray, NULL);
+ if (!pProcId) return throw_runtime_exception(env, "JNI call GetPrimitiveArrayCritical(processIdArray, &isCopy) failed");
+
+ *pProcId = procId;
+ (*env)->ReleasePrimitiveArrayCritical(env, processIdArray, pProcId, 0);
+
+ return ptm;
+}
+
+JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_setPtyWindowSize(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fd, jint rows, jint cols, jint cell_width, jint cell_height)
+{
+ struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) cols, .ws_xpixel = (unsigned short) (cols * cell_width), .ws_ypixel = (unsigned short) (rows * cell_height) };
+ ioctl(fd, TIOCSWINSZ, &sz);
+}
+
+JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_setPtyUTF8Mode(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fd)
+{
+ struct termios tios;
+ tcgetattr(fd, &tios);
+ if ((tios.c_iflag & IUTF8) == 0) {
+ tios.c_iflag |= IUTF8;
+ tcsetattr(fd, TCSANOW, &tios);
+ }
+}
+
+JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_waitFor(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint pid)
+{
+ int status;
+ waitpid(pid, &status, 0);
+ if (WIFEXITED(status)) {
+ return WEXITSTATUS(status);
+ } else if (WIFSIGNALED(status)) {
+ return -WTERMSIG(status);
+ } else {
+ // Should never happen - waitpid(2) says "One of the first three macros will evaluate to a non-zero (true) value".
+ return 0;
+ }
+}
+
+JNIEXPORT void JNICALL Java_com_termux_terminal_JNI_close(JNIEnv* TERMUX_UNUSED(env), jclass TERMUX_UNUSED(clazz), jint fileDescriptor)
+{
+ close(fileDescriptor);
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/ApcTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/ApcTest.java
new file mode 100644
index 0000000..4f6292a
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/ApcTest.java
@@ -0,0 +1,21 @@
+package com.termux.terminal;
+
+public class ApcTest extends TerminalTestCase {
+
+ public void testApcConsumed() {
+ // At time of writing this is part of what yazi sends for probing for kitty graphics protocol support:
+ // https://github.com/sxyazi/yazi/blob/0cdaff98d0b3723caff63eebf1974e7907a43a2c/yazi-adapter/src/emulator.rs#L129
+ // This should not result in anything being written to the screen: If kitty graphics protocol support
+ // is implemented it should instead result in an error code on stdin, and if not it should be consumed
+ // silently just as xterm does. See https://sw.kovidgoyal.net/kitty/graphics-protocol/.
+ withTerminalSized(2, 2)
+ .enterString("\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\")
+ .assertLinesAre(" ", " ");
+
+ // It is ok for the APC content to be non printable characters:
+ withTerminalSized(12, 2)
+ .enterString("hello \033_some\023\033_\\apc#end\033\\ world")
+ .assertLinesAre("hello world", " ");
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/ByteQueueTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/ByteQueueTest.java
new file mode 100644
index 0000000..44a605b
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/ByteQueueTest.java
@@ -0,0 +1,54 @@
+package com.termux.terminal;
+
+import junit.framework.TestCase;
+
+public class ByteQueueTest extends TestCase {
+
+ private static void assertArrayEquals(byte[] expected, byte[] actual) {
+ if (expected.length != actual.length) {
+ fail("Difference array length");
+ }
+ for (int i = 0; i < expected.length; i++) {
+ if (expected[i] != actual[i]) {
+ fail("Inequals at index=" + i + ", expected=" + (int) expected[i] + ", actual=" + (int) actual[i]);
+ }
+ }
+ }
+
+ public void testCompleteWrites() throws Exception {
+ ByteQueue q = new ByteQueue(10);
+ assertTrue(q.write(new byte[]{1, 2, 3}, 0, 3));
+
+ byte[] arr = new byte[10];
+ assertEquals(3, q.read(arr, true));
+ assertArrayEquals(new byte[]{1, 2, 3}, new byte[]{arr[0], arr[1], arr[2]});
+
+ assertTrue(q.write(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 0, 10));
+ assertEquals(10, q.read(arr, true));
+ assertArrayEquals(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, arr);
+ }
+
+ public void testQueueWraparound() throws Exception {
+ ByteQueue q = new ByteQueue(10);
+
+ byte[] origArray = new byte[]{1, 2, 3, 4, 5, 6};
+ byte[] readArray = new byte[origArray.length];
+ for (int i = 0; i < 20; i++) {
+ q.write(origArray, 0, origArray.length);
+ assertEquals(origArray.length, q.read(readArray, true));
+ assertArrayEquals(origArray, readArray);
+ }
+ }
+
+ public void testWriteNotesClosing() throws Exception {
+ ByteQueue q = new ByteQueue(10);
+ q.close();
+ assertFalse(q.write(new byte[]{1, 2, 3}, 0, 3));
+ }
+
+ public void testReadNonBlocking() throws Exception {
+ ByteQueue q = new ByteQueue(10);
+ assertEquals(0, q.read(new byte[128], false));
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java
new file mode 100644
index 0000000..9f123bc
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java
@@ -0,0 +1,131 @@
+package com.termux.terminal;
+
+import java.util.List;
+
+/** "\033[" is the Control Sequence Introducer char sequence (CSI). */
+public class ControlSequenceIntroducerTest extends TerminalTestCase {
+
+ /** CSI Ps P Scroll down Ps lines (default = 1) (SD). */
+ public void testCsiT() {
+ withTerminalSized(4, 6).enterString("1\r\n2\r\n3\r\nhi\033[2Tyo\r\nA\r\nB").assertLinesAre(" ", " ", "1 ", "2 yo", "A ",
+ "Bi ");
+ // Default value (1):
+ withTerminalSized(4, 6).enterString("1\r\n2\r\n3\r\nhi\033[Tyo\r\nA\r\nB").assertLinesAre(" ", "1 ", "2 ", "3 yo", "Ai ",
+ "B ");
+ }
+
+ /** CSI Ps S Scroll up Ps lines (default = 1) (SU). */
+ public void testCsiS() {
+ // The behaviour here is a bit inconsistent between terminals - this is how the OS X Terminal.app does it:
+ withTerminalSized(3, 4).enterString("1\r\n2\r\n3\r\nhi\033[2Sy").assertLinesAre("3 ", "hi ", " ", " y");
+ // Default value (1):
+ withTerminalSized(3, 4).enterString("1\r\n2\r\n3\r\nhi\033[Sy").assertLinesAre("2 ", "3 ", "hi ", " y");
+ }
+
+ /** CSI Ps X Erase Ps Character(s) (default = 1) (ECH). */
+ public void testCsiX() {
+ // See https://code.google.com/p/chromium/issues/detail?id=212712 where test was extraced from.
+ withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[X").assertLinesAre("abcdefg ijkl ", " ");
+ withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[1X").assertLinesAre("abcdefg ijkl ", " ");
+ withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[2X").assertLinesAre("abcdefg jkl ", " ");
+ withTerminalSized(13, 2).enterString("abcdefghijkl\b\b\b\b\b\033[20X").assertLinesAre("abcdefg ", " ");
+ }
+
+ /** CSI Pm m Set SGR parameter(s) from semicolon-separated list Pm. */
+ public void testCsiSGRParameters() {
+ // Set more parameters (19) than supported (16). Additional parameters should be silently consumed.
+ withTerminalSized(3, 2).enterString("\033[0;38;2;255;255;255;48;2;0;0;0;1;2;3;4;5;7;8;9mabc").assertLinesAre("abc", " ");
+ }
+
+ /** CSI Ps b Repeat the preceding graphic character Ps times (REP). */
+ public void testRepeat() {
+ withTerminalSized(3, 2).enterString("a\033[b").assertLinesAre("aa ", " ");
+ withTerminalSized(3, 2).enterString("a\033[2b").assertLinesAre("aaa", " ");
+ // When no char has been output we ignore REP:
+ withTerminalSized(3, 2).enterString("\033[b").assertLinesAre(" ", " ");
+ // This shows that REP outputs the last emitted code point and not the one relative to the
+ // current cursor position:
+ withTerminalSized(5, 2).enterString("abcde\033[2G\033[2b\n").assertLinesAre("aeede", " ");
+ }
+
+ /** CSI 3 J Clear scrollback (xterm, libvte; non-standard). */
+ public void testCsi3J() {
+ withTerminalSized(3, 2).enterString("a\r\nb\r\nc\r\nd");
+ assertEquals("a\nb\nc\nd", mTerminal.getScreen().getTranscriptText());
+ enterString("\033[3J");
+ assertEquals("c\nd", mTerminal.getScreen().getTranscriptText());
+
+ withTerminalSized(3, 2).enterString("Lorem_ipsum");
+ assertEquals("Lorem_ipsum", mTerminal.getScreen().getTranscriptText());
+ enterString("\033[3J");
+ assertEquals("ipsum", mTerminal.getScreen().getTranscriptText());
+
+ withTerminalSized(3, 2).enterString("w\r\nx\r\ny\r\nz\033[?1049h\033[3J\033[?1049l");
+ assertEquals("y\nz", mTerminal.getScreen().getTranscriptText());
+ }
+
+ public void testReportPixelSize() {
+ int columns = 3;
+ int rows = 3;
+ withTerminalSized(columns, rows);
+ int cellWidth = TerminalTest.INITIAL_CELL_WIDTH_PIXELS;
+ int cellHeight = TerminalTest.INITIAL_CELL_HEIGHT_PIXELS;
+ assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t");
+ assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t");
+ columns = 23;
+ rows = 33;
+ resize(columns, rows);
+ assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t");
+ assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t");
+ cellWidth = 8;
+ cellHeight = 18;
+ mTerminal.resize(columns, rows, cellWidth, cellHeight);
+ assertEnteringStringGivesResponse("\033[14t", "\033[4;" + (rows*cellHeight) + ";" + (columns*cellWidth) + "t");
+ assertEnteringStringGivesResponse("\033[16t", "\033[6;" + cellHeight + ";" + cellWidth + "t");
+ }
+
+ /**
+ * See Colored and styled underlines :
+ *
+ *
+ * [4:0m # no underline
+ * [4:1m # straight underline
+ * [4:2m # double underline
+ * [4:3m # curly underline
+ * [4:4m # dotted underline
+ * [4:5m # dashed underline
+ * [4m # straight underline (for backwards compat)
+ * [24m # no underline (for backwards compat)
+ *
+ *
+ * We currently parse the variants, but map them to normal/no underlines as appropriate
+ */
+ public void testUnderlineVariants() {
+ for (String suffix : List.of("", ":1", ":2", ":3", ":4", ":5")) {
+ for (String stop : List.of("24", "4:0")) {
+ withTerminalSized(3, 3);
+ enterString("\033[4" + suffix + "m").assertLinesAre(" ", " ", " ");
+ assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect);
+ enterString("\033[4;1m").assertLinesAre(" ", " ", " ");
+ assertEquals(TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect);
+ enterString("\033[" + stop + "m").assertLinesAre(" ", " ", " ");
+ assertEquals(TextStyle.CHARACTER_ATTRIBUTE_BOLD, mTerminal.mEffect);
+ }
+ }
+ }
+
+ public void testManyParameters() {
+ StringBuilder b = new StringBuilder("\033[");
+ for (int i = 0; i < 30; i++) {
+ b.append("0;");
+ }
+ b.append("4:2");
+ // This clearing of underline should be ignored as the parameters pass the threshold for too many parameters:
+ b.append("4:0m");
+ withTerminalSized(3, 3)
+ .enterString(b.toString())
+ .assertLinesAre(" ", " ", " ");
+ assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, mTerminal.mEffect);
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/CursorAndScreenTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/CursorAndScreenTest.java
new file mode 100644
index 0000000..8a65e66
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/CursorAndScreenTest.java
@@ -0,0 +1,266 @@
+package com.termux.terminal;
+
+import org.junit.Assert;
+
+public class CursorAndScreenTest extends TerminalTestCase {
+
+ public void testDeleteLinesKeepsStyles() {
+ int cols = 5, rows = 5;
+ withTerminalSized(cols, rows);
+ for (int row = 0; row < 5; row++) {
+ for (int col = 0; col < 5; col++) {
+ // Foreground color to col, background to row:
+ enterString("\033[38;5;" + col + "m");
+ enterString("\033[48;5;" + row + "m");
+ enterString(Character.toString((char) ('A' + col + row * 5)));
+ }
+ }
+ assertLinesAre("ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY");
+ for (int row = 0; row < 5; row++) {
+ for (int col = 0; col < 5; col++) {
+ long s = getStyleAt(row, col);
+ Assert.assertEquals(col, TextStyle.decodeForeColor(s));
+ Assert.assertEquals(row, TextStyle.decodeBackColor(s));
+ }
+ }
+ // "${CSI}H" - place cursor at 1,1, then "${CSI}2M" to delete two lines.
+ enterString("\033[H\033[2M");
+ assertLinesAre("KLMNO", "PQRST", "UVWXY", " ", " ");
+ for (int row = 0; row < 3; row++) {
+ for (int col = 0; col < 5; col++) {
+ long s = getStyleAt(row, col);
+ Assert.assertEquals(col, TextStyle.decodeForeColor(s));
+ Assert.assertEquals(row + 2, TextStyle.decodeBackColor(s));
+ }
+ }
+ // Set default fg and background for the new blank lines:
+ enterString("\033[38;5;98m");
+ enterString("\033[48;5;99m");
+ // "${CSI}B" to go down one line, then "${CSI}2L" to insert two lines:
+ enterString("\033[B\033[2L");
+ assertLinesAre("KLMNO", " ", " ", "PQRST", "UVWXY");
+ for (int row = 0; row < 5; row++) {
+ for (int col = 0; col < 5; col++) {
+ int wantedForeground = (row == 1 || row == 2) ? 98 : col;
+ int wantedBackground = (row == 1 || row == 2) ? 99 : (row == 0 ? 2 : row);
+ long s = getStyleAt(row, col);
+ Assert.assertEquals(wantedForeground, TextStyle.decodeForeColor(s));
+ Assert.assertEquals(wantedBackground, TextStyle.decodeBackColor(s));
+ }
+ }
+ }
+
+ public void testDeleteCharacters() {
+ withTerminalSized(5, 2).enterString("枝ce").assertLinesAre("枝ce ", " ");
+ withTerminalSized(5, 2).enterString("a枝ce").assertLinesAre("a枝ce", " ");
+ withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[P").assertLinesAre("ice ", " ");
+ withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[2P").assertLinesAre("ce ", " ");
+ withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[2P").assertLinesAre("ne ", " ");
+ // "${CSI}${n}P, the delete characters (DCH) sequence should cap characters to delete.
+ withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[99P").assertLinesAre(" ", " ");
+ // With combining char U+0302.
+ withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[2P").assertLinesAre("ce ", " ");
+ withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[P").assertLinesAre("ice ", " ");
+ withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[2G\033[2P").assertLinesAre("n\u0302e ", " ");
+ // With wide 枝 char, checking that putting char at part replaces other with whitespace:
+ withTerminalSized(5, 2).enterString("枝ce").enterString("\033[Ga").assertLinesAre("a ce ", " ");
+ withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2Ga").assertLinesAre(" ace ", " ");
+ // With wide 枝 char, deleting either part replaces other with whitespace:
+ withTerminalSized(5, 2).enterString("枝ce").enterString("\033[G\033[P").assertLinesAre(" ce ", " ");
+ withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2G\033[P").assertLinesAre(" ce ", " ");
+ withTerminalSized(5, 2).enterString("枝ce").enterString("\033[2G\033[2P").assertLinesAre(" e ", " ");
+ withTerminalSized(5, 2).enterString("枝ce").enterString("\033[G\033[2P").assertLinesAre("ce ", " ");
+ withTerminalSized(5, 2).enterString("a枝ce").enterString("\033[G\033[P").assertLinesAre("枝ce ", " ");
+ }
+
+ public void testInsertMode() {
+ // "${CSI}4h" enables insert mode.
+ withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4hA").assertLinesAre("Anice", " ");
+ withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[4hA").assertLinesAre("nAice", " ");
+ withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4hABC").assertLinesAre("ABCni", " ");
+ // With combining char U+0302.
+ withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[4hA").assertLinesAre("An\u0302ice", " ");
+ withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[G\033[4hAB").assertLinesAre("ABn\u0302ic", " ");
+ withTerminalSized(5, 2).enterString("n\u0302ic\u0302e").enterString("\033[2G\033[4hA").assertLinesAre("n\u0302Aic\u0302e", " ");
+ // ... but without insert mode, combining char should be overwritten:
+ withTerminalSized(5, 2).enterString("n\u0302ice").enterString("\033[GA").assertLinesAre("Aice ", " ");
+ // ... also with two combining:
+ withTerminalSized(5, 2).enterString("n\u0302\u0302i\u0302ce").enterString("\033[GA").assertLinesAre("Ai\u0302ce ", " ");
+ // ... and in last column:
+ withTerminalSized(5, 2).enterString("n\u0302\u0302ice!\u0302").enterString("\033[5GA").assertLinesAre("n\u0302\u0302iceA", " ");
+ withTerminalSized(5, 2).enterString("nic\u0302e!\u0302").enterString("\033[4G枝").assertLinesAre("nic\u0302枝", " ");
+ withTerminalSized(5, 2).enterString("nic枝\u0302").enterString("\033[3GA").assertLinesAre("niA枝\u0302", " ");
+ withTerminalSized(5, 2).enterString("nic枝\u0302").enterString("\033[3GA").assertLinesAre("niA枝\u0302", " ");
+ // With wide 枝 char.
+ withTerminalSized(5, 2).enterString("nice").enterString("\033[G\033[4h枝").assertLinesAre("枝nic", " ");
+ withTerminalSized(5, 2).enterString("nice").enterString("\033[2G\033[4h枝").assertLinesAre("n枝ic", " ");
+ withTerminalSized(5, 2).enterString("n枝ce").enterString("\033[G\033[4ha").assertLinesAre("an枝c", " ");
+ }
+
+ /** HPA—Horizontal Position Absolute (http://www.vt100.net/docs/vt510-rm/HPA) */
+ public void testCursorHorizontalPositionAbsolute() {
+ withTerminalSized(4, 4).enterString("ABC\033[`").assertCursorAt(0, 0);
+ enterString("\033[1`").assertCursorAt(0, 0).enterString("\033[2`").assertCursorAt(0, 1);
+ enterString("\r\n\033[3`").assertCursorAt(1, 2).enterString("\033[22`").assertCursorAt(1, 3);
+ // Enable and configure right and left margins, first without origin mode:
+ enterString("\033[?69h\033[2;3s\033[`").assertCursorAt(0, 0).enterString("\033[22`").assertCursorAt(0, 3);
+ // .. now with origin mode:
+ enterString("\033[?6h\033[`").assertCursorAt(0, 1).enterString("\033[22`").assertCursorAt(0, 2);
+ }
+
+ public void testCursorForward() {
+ // "${CSI}${N:=1}C" moves cursor forward N columns:
+ withTerminalSized(6, 2).enterString("A\033[CB\033[2CC").assertLinesAre("A B C", " ");
+ // If an attempt is made to move the cursor to the right of the right margin, the cursor stops at the right margin:
+ withTerminalSized(6, 2).enterString("A\033[44CB").assertLinesAre("A B", " ");
+ // Enable right margin and verify that CUF ends at the set right margin:
+ withTerminalSized(6, 2).enterString("\033[?69h\033[1;3s\033[44CAB").assertLinesAre(" A ", "B ");
+ }
+
+ public void testCursorBack() {
+ // "${CSI}${N:=1}D" moves cursor back N columns:
+ withTerminalSized(3, 2).enterString("A\033[DB").assertLinesAre("B ", " ");
+ withTerminalSized(3, 2).enterString("AB\033[2DC").assertLinesAre("CB ", " ");
+ // If an attempt is made to move the cursor to the left of the left margin, the cursor stops at the left margin:
+ withTerminalSized(3, 2).enterString("AB\033[44DC").assertLinesAre("CB ", " ");
+ // Enable left margin and verify that CUB ends at the set left margin:
+ withTerminalSized(6, 2).enterString("ABCD\033[?69h\033[2;6s\033[44DE").assertLinesAre("AECD ", " ");
+ }
+
+ public void testCursorUp() {
+ // "${CSI}${N:=1}A" moves cursor up N rows:
+ withTerminalSized(3, 3).enterString("ABCDEFG\033[AH").assertLinesAre("ABC", "DHF", "G ");
+ withTerminalSized(3, 3).enterString("ABCDEFG\033[2AH").assertLinesAre("AHC", "DEF", "G ");
+ // If an attempt is made to move the cursor above the top margin, the cursor stops at the top margin:
+ withTerminalSized(3, 3).enterString("ABCDEFG\033[44AH").assertLinesAre("AHC", "DEF", "G ");
+ }
+
+ public void testCursorDown() {
+ // "${CSI}${N:=1}B" moves cursor down N rows:
+ withTerminalSized(3, 3).enterString("AB\033[BC").assertLinesAre("AB ", " C", " ");
+ withTerminalSized(3, 3).enterString("AB\033[2BC").assertLinesAre("AB ", " ", " C");
+ // If an attempt is made to move the cursor below the bottom margin, the cursor stops at the bottom margin:
+ withTerminalSized(3, 3).enterString("AB\033[44BC").assertLinesAre("AB ", " ", " C");
+ }
+
+ public void testReportCursorPosition() {
+ withTerminalSized(10, 10);
+ for (int i = 0; i < 10; i++) {
+ for (int j = 0; j < 10; j++) {
+ enterString("\033[" + (i + 1) + ";" + (j + 1) + "H"); // CUP cursor position.
+ assertCursorAt(i, j);
+ // Device Status Report (DSR):
+ assertEnteringStringGivesResponse("\033[6n", "\033[" + (i + 1) + ";" + (j + 1) + "R");
+ // DECXCPR — Extended Cursor Position. Note that http://www.vt100.net/docs/vt510-rm/DECXCPR says
+ // the response is "${CSI}${LINE};${COLUMN};${PAGE}R" while xterm (http://invisible-island.net/xterm/ctlseqs/ctlseqs.html)
+ // drops the question mark. Expect xterm behaviour here.
+ assertEnteringStringGivesResponse("\033[?6n", "\033[?" + (i + 1) + ";" + (j + 1) + ";1R");
+ }
+ }
+ }
+
+ /**
+ * See comments on horizontal tab handling in TerminalEmulator.java.
+ *
+ * We do not want to color already written cells when tabbing over them.
+ */
+ public void DISABLED_testHorizontalTabColorsBackground() {
+ withTerminalSized(10, 3).enterString("\033[48;5;15m").enterString("\t");
+ assertCursorAt(0, 8);
+ for (int i = 0; i < 10; i++) {
+ int expectedColor = i < 8 ? 15 : TextStyle.COLOR_INDEX_BACKGROUND;
+ assertEquals(expectedColor, TextStyle.decodeBackColor(getStyleAt(0, i)));
+ }
+ }
+
+ /**
+ * Test interactions between the cursor overflow bit and various escape sequences.
+ *
+ * Adapted from hterm:
+ * https://chromium.googlesource.com/chromiumos/platform/assets/+/2337afa5c063127d5ce40ec7fec9b602d096df86%5E%21/#F2
+ */
+ public void testClearingOfAutowrap() {
+ // Fill a row with the last hyphen wrong, then run a command that
+ // modifies the screen, then add a hyphen. The wrap bit should be
+ // cleared, so the extra hyphen can fix the row.
+ withTerminalSized(15, 6);
+
+ enterString("----- 1 ----X");
+ enterString("\033[K-"); // EL
+
+ enterString("----- 2 ----X");
+ enterString("\033[J-"); // ED
+
+ enterString("----- 3 ----X");
+ enterString("\033[@-"); // ICH
+
+ enterString("----- 4 ----X");
+ enterString("\033[P-"); // DCH
+
+ enterString("----- 5 ----X");
+ enterString("\033[X-"); // ECH
+
+ // DL will delete the entire line but clear the wrap bit, so we
+ // expect a hyphen at the end and nothing else.
+ enterString("XXXXXXXXXXXXXXX");
+ enterString("\033[M-"); // DL
+
+ assertLinesAre(
+ "----- 1 -----",
+ "----- 2 -----",
+ "----- 3 -----",
+ "----- 4 -----",
+ "----- 5 -----",
+ " -");
+ }
+
+ public void testBackspaceAcrossWrappedLines() {
+ // Backspace should not go to previous line if not auto-wrapped:
+ withTerminalSized(3, 3).enterString("hi\r\n\b\byou").assertLinesAre("hi ", "you", " ");
+ // Backspace should go to previous line if auto-wrapped:
+ withTerminalSized(3, 3).enterString("hi y").assertLinesAre("hi ", "y ", " ").enterString("\b\b#").assertLinesAre("hi#", "y ", " ");
+ // Initial backspace should do nothing:
+ withTerminalSized(3, 3).enterString("\b\b\b\bhi").assertLinesAre("hi ", " ", " ");
+ }
+
+ public void testCursorSaveRestoreLocation() {
+ // DEC save/restore
+ withTerminalSized(4, 2).enterString("t\0337est\r\nme\0338ry ").assertLinesAre("try ", "me ");
+ // ANSI.SYS save/restore
+ withTerminalSized(4, 2).enterString("t\033[sest\r\nme\033[ury ").assertLinesAre("try ", "me ");
+ // Alternate screen enter/exit
+ withTerminalSized(4, 2).enterString("t\033[?1049h\033[Hest\r\nme").assertLinesAre("est ", "me ").enterString("\033[?1049lry").assertLinesAre("try ", " ");
+ }
+
+ public void testCursorSaveRestoreTextStyle() {
+ long s;
+
+ // DEC save/restore
+ withTerminalSized(4, 2).enterString("\033[31;42;4m..\0337\033[36;47;24m\0338..");
+ s = getStyleAt(0, 3);
+ Assert.assertEquals(1, TextStyle.decodeForeColor(s));
+ Assert.assertEquals(2, TextStyle.decodeBackColor(s));
+ Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s));
+
+ // ANSI.SYS save/restore
+ withTerminalSized(4, 2).enterString("\033[31;42;4m..\033[s\033[36;47;24m\033[u..");
+ s = getStyleAt(0, 3);
+ Assert.assertEquals(1, TextStyle.decodeForeColor(s));
+ Assert.assertEquals(2, TextStyle.decodeBackColor(s));
+ Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s));
+
+ // Alternate screen enter/exit
+ withTerminalSized(4, 2);
+ enterString("\033[31;42;4m..\033[?1049h\033[H\033[36;47;24m.");
+ s = getStyleAt(0, 0);
+ Assert.assertEquals(6, TextStyle.decodeForeColor(s));
+ Assert.assertEquals(7, TextStyle.decodeBackColor(s));
+ Assert.assertEquals(0, TextStyle.decodeEffect(s));
+ enterString("\033[?1049l..");
+ s = getStyleAt(0, 3);
+ Assert.assertEquals(1, TextStyle.decodeForeColor(s));
+ Assert.assertEquals(2, TextStyle.decodeBackColor(s));
+ Assert.assertEquals(TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.decodeEffect(s));
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/DecSetTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/DecSetTest.java
new file mode 100644
index 0000000..f31f1fb
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/DecSetTest.java
@@ -0,0 +1,78 @@
+package com.termux.terminal;
+
+/**
+ *
+ * "CSI ? Pm h", DEC Private Mode Set (DECSET)
+ *
+ *
+ * and
+ *
+ *
+ * "CSI ? Pm l", DEC Private Mode Reset (DECRST)
+ *
+ *
+ * controls various aspects of the terminal
+ */
+public class DecSetTest extends TerminalTestCase {
+
+ /** DECSET 25, DECTCEM, controls visibility of the cursor. */
+ public void testEnableDisableCursor() {
+ withTerminalSized(3, 3);
+ assertTrue("Initially the cursor should be enabled", mTerminal.isCursorEnabled());
+ enterString("\033[?25l"); // Disable Cursor (DECTCEM).
+ assertFalse(mTerminal.isCursorEnabled());
+ enterString("\033[?25h"); // Enable Cursor (DECTCEM).
+ assertTrue(mTerminal.isCursorEnabled());
+
+ enterString("\033[?25l"); // Disable Cursor (DECTCEM), again.
+ assertFalse(mTerminal.isCursorEnabled());
+ mTerminal.reset();
+ assertTrue("Resetting the terminal should enable the cursor", mTerminal.isCursorEnabled());
+
+ enterString("\033[?25l");
+ assertFalse(mTerminal.isCursorEnabled());
+ enterString("\033c"); // RIS resetting should enabled cursor.
+ assertTrue(mTerminal.isCursorEnabled());
+ }
+
+ /** DECSET 2004, controls bracketed paste mode. */
+ public void testBracketedPasteMode() {
+ withTerminalSized(3, 3);
+
+ mTerminal.paste("a");
+ assertEquals("Pasting 'a' should output 'a' when bracketed paste mode is disabled", "a", mOutput.getOutputAndClear());
+
+ enterString("\033[?2004h"); // Enable bracketed paste mode.
+ mTerminal.paste("a");
+ assertEquals("Pasting when in bracketed paste mode should be bracketed", "\033[200~a\033[201~", mOutput.getOutputAndClear());
+
+ enterString("\033[?2004l"); // Disable bracketed paste mode.
+ mTerminal.paste("a");
+ assertEquals("Pasting 'a' should output 'a' when bracketed paste mode is disabled", "a", mOutput.getOutputAndClear());
+
+ enterString("\033[?2004h"); // Enable bracketed paste mode, again.
+ mTerminal.paste("a");
+ assertEquals("Pasting when in bracketed paste mode again should be bracketed", "\033[200~a\033[201~", mOutput.getOutputAndClear());
+
+ mTerminal.paste("\033ab\033cd\033");
+ assertEquals("Pasting an escape character should not input it", "\033[200~abcd\033[201~", mOutput.getOutputAndClear());
+ mTerminal.paste("\u0081ab\u0081cd\u009F");
+ assertEquals("Pasting C1 control codes should not input it", "\033[200~abcd\033[201~", mOutput.getOutputAndClear());
+
+ mTerminal.reset();
+ mTerminal.paste("a");
+ assertEquals("Terminal reset() should disable bracketed paste mode", "a", mOutput.getOutputAndClear());
+ }
+
+ /** DECSET 7, DECAWM, controls wraparound mode. */
+ public void testWrapAroundMode() {
+ // Default with wraparound:
+ withTerminalSized(3, 3).enterString("abcd").assertLinesAre("abc", "d ", " ");
+ // With wraparound disabled:
+ withTerminalSized(3, 3).enterString("\033[?7labcd").assertLinesAre("abd", " ", " ");
+ enterString("efg").assertLinesAre("abg", " ", " ");
+ // Re-enabling wraparound:
+ enterString("\033[?7hhij").assertLinesAre("abh", "ij ", " ");
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/DeviceControlStringTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/DeviceControlStringTest.java
new file mode 100644
index 0000000..f889935
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/DeviceControlStringTest.java
@@ -0,0 +1,53 @@
+package com.termux.terminal;
+
+/**
+ * "\033P" is a device control string.
+ */
+public class DeviceControlStringTest extends TerminalTestCase {
+
+ private static String hexEncode(String s) {
+ StringBuilder result = new StringBuilder();
+ for (int i = 0; i < s.length(); i++)
+ result.append(String.format("%02X", (int) s.charAt(i)));
+ return result.toString();
+ }
+
+ private void assertCapabilityResponse(String cap, String expectedResponse) {
+ String input = "\033P+q" + hexEncode(cap) + "\033\\";
+ assertEnteringStringGivesResponse(input, "\033P1+r" + hexEncode(cap) + "=" + hexEncode(expectedResponse) + "\033\\");
+ }
+
+ public void testReportColorsAndName() {
+ // Request Termcap/Terminfo String. The string following the "q" is a list of names encoded in
+ // hexadecimal (2 digits per character) separated by ; which correspond to termcap or terminfo key
+ // names.
+ // Two special features are also recognized, which are not key names: Co for termcap colors (or colors
+ // for terminfo colors), and TN for termcap name (or name for terminfo name).
+ // xterm responds with DCS 1 + r P t ST for valid requests, adding to P t an = , and the value of the
+ // corresponding string that xterm would send, or DCS 0 + r P t ST for invalid requests. The strings are
+ // encoded in hexadecimal (2 digits per character).
+ withTerminalSized(3, 3).enterString("A");
+ assertCapabilityResponse("Co", "256");
+ assertCapabilityResponse("colors", "256");
+ assertCapabilityResponse("TN", "xterm");
+ assertCapabilityResponse("name", "xterm");
+ enterString("B").assertLinesAre("AB ", " ", " ");
+ }
+
+ public void testReportKeys() {
+ withTerminalSized(3, 3);
+ assertCapabilityResponse("kB", "\033[Z");
+ }
+
+ public void testReallyLongDeviceControlString() {
+ withTerminalSized(3, 3).enterString("\033P");
+ for (int i = 0; i < 10000; i++) {
+ enterString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+ }
+ // The terminal should ignore the overlong DCS sequence and continue printing "aaa." and fill at least the first two lines with
+ // them:
+ assertLineIs(0, "aaa");
+ assertLineIs(1, "aaa");
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/HistoryTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/HistoryTest.java
new file mode 100644
index 0000000..17f8111
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/HistoryTest.java
@@ -0,0 +1,33 @@
+package com.termux.terminal;
+
+
+public class HistoryTest extends TerminalTestCase {
+
+ public void testHistory() {
+ final int rows = 3;
+ final int cols = 3;
+ withTerminalSized(cols, rows).enterString("111222333444555666777888999");
+ assertCursorAt(2, 2);
+ assertLinesAre("777", "888", "999");
+ assertHistoryStartsWith("666", "555");
+
+ resize(cols, 2);
+ assertHistoryStartsWith("777", "666", "555");
+
+ resize(cols, 3);
+ assertHistoryStartsWith("666", "555");
+ }
+
+ public void testHistoryWithScrollRegion() {
+ // "CSI P_s ; P_s r" - set Scrolling Region [top;bottom] (default = full size of window) (DECSTBM).
+ withTerminalSized(3, 4).enterString("111222333444");
+ assertLinesAre("111", "222", "333", "444");
+ enterString("\033[2;3r");
+ // NOTE: "DECSTBM moves the cursor to column 1, line 1 of the page."
+ assertCursorAt(0, 0);
+ enterString("\nCDEFGH").assertLinesAre("111", "CDE", "FGH", "444");
+ enterString("IJK").assertLinesAre("111", "FGH", "IJK", "444").assertHistoryStartsWith("CDE");
+ enterString("LMN").assertLinesAre("111", "IJK", "LMN", "444").assertHistoryStartsWith("FGH", "CDE");
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/KeyHandlerTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/KeyHandlerTest.java
new file mode 100644
index 0000000..15b2376
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/KeyHandlerTest.java
@@ -0,0 +1,203 @@
+package com.termux.terminal;
+
+import android.view.KeyEvent;
+
+import junit.framework.TestCase;
+
+public class KeyHandlerTest extends TestCase {
+
+ private static String stringToHex(String s) {
+ if (s == null) return null;
+ StringBuilder buffer = new StringBuilder();
+ for (int i = 0; i < s.length(); i++) {
+ if (buffer.length() > 0) {
+ buffer.append(" ");
+ }
+ buffer.append("0x");
+ buffer.append(Integer.toHexString(s.charAt(i)));
+ }
+ return buffer.toString();
+ }
+
+ private static void assertKeysEquals(String expected, String actual) {
+ if (!expected.equals(actual)) {
+ assertEquals(stringToHex(expected), stringToHex(actual));
+ }
+ }
+
+ /** See http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html */
+ public void testTermCaps() {
+ // Backspace.
+ assertKeysEquals("\u007f", KeyHandler.getCodeFromTermcap("kb", false, false));
+
+ // Back tab.
+ assertKeysEquals("\033[Z", KeyHandler.getCodeFromTermcap("kB", false, false));
+
+ // Arrow keys (up/down/right/left):
+ assertKeysEquals("\033[A", KeyHandler.getCodeFromTermcap("ku", false, false));
+ assertKeysEquals("\033[B", KeyHandler.getCodeFromTermcap("kd", false, false));
+ assertKeysEquals("\033[C", KeyHandler.getCodeFromTermcap("kr", false, false));
+ assertKeysEquals("\033[D", KeyHandler.getCodeFromTermcap("kl", false, false));
+ // .. shifted:
+ assertKeysEquals("\033[1;2A", KeyHandler.getCodeFromTermcap("kUP", false, false));
+ assertKeysEquals("\033[1;2B", KeyHandler.getCodeFromTermcap("kDN", false, false));
+ assertKeysEquals("\033[1;2C", KeyHandler.getCodeFromTermcap("%i", false, false));
+ assertKeysEquals("\033[1;2D", KeyHandler.getCodeFromTermcap("#4", false, false));
+
+ // Home/end keys:
+ assertKeysEquals("\033[H", KeyHandler.getCodeFromTermcap("kh", false, false));
+ assertKeysEquals("\033[F", KeyHandler.getCodeFromTermcap("@7", false, false));
+ // ... shifted:
+ assertKeysEquals("\033[1;2H", KeyHandler.getCodeFromTermcap("#2", false, false));
+ assertKeysEquals("\033[1;2F", KeyHandler.getCodeFromTermcap("*7", false, false));
+
+ // The traditional keyboard keypad:
+ // [Insert] [Home] [Page Up ]
+ // [Delete] [End] [Page Down]
+ //
+ // Termcap names (with xterm response in parenthesis):
+ // K1=Upper left of keypad (xterm sends same "[H" = Home).
+ // K2=Center of keypad (xterm sends invalid response).
+ // K3=Upper right of keypad (xterm sends "[5~" = Page Up).
+ // K4=Lower left of keypad (xterm sends "[F" = End key).
+ // K5=Lower right of keypad (xterm sends "[6~" = Page Down).
+ //
+ // vim/neovim (runtime/doc/term.txt):
+ // t_K1 keypad home key
+ // t_K3 keypad page-up key
+ // t_K4 keypad end key
+ // t_K5 keypad page-down key
+ //
+ assertKeysEquals("\033[H", KeyHandler.getCodeFromTermcap("K1", false, false));
+ assertKeysEquals("\033OH", KeyHandler.getCodeFromTermcap("K1", true, false));
+ assertKeysEquals("\033[5~", KeyHandler.getCodeFromTermcap("K3", false, false));
+ assertKeysEquals("\033[F", KeyHandler.getCodeFromTermcap("K4", false, false));
+ assertKeysEquals("\033OF", KeyHandler.getCodeFromTermcap("K4", true, false));
+ assertKeysEquals("\033[6~", KeyHandler.getCodeFromTermcap("K5", false, false));
+
+ // Function keys F1-F12:
+ assertKeysEquals("\033OP", KeyHandler.getCodeFromTermcap("k1", false, false));
+ assertKeysEquals("\033OQ", KeyHandler.getCodeFromTermcap("k2", false, false));
+ assertKeysEquals("\033OR", KeyHandler.getCodeFromTermcap("k3", false, false));
+ assertKeysEquals("\033OS", KeyHandler.getCodeFromTermcap("k4", false, false));
+ assertKeysEquals("\033[15~", KeyHandler.getCodeFromTermcap("k5", false, false));
+ assertKeysEquals("\033[17~", KeyHandler.getCodeFromTermcap("k6", false, false));
+ assertKeysEquals("\033[18~", KeyHandler.getCodeFromTermcap("k7", false, false));
+ assertKeysEquals("\033[19~", KeyHandler.getCodeFromTermcap("k8", false, false));
+ assertKeysEquals("\033[20~", KeyHandler.getCodeFromTermcap("k9", false, false));
+ assertKeysEquals("\033[21~", KeyHandler.getCodeFromTermcap("k;", false, false));
+ assertKeysEquals("\033[23~", KeyHandler.getCodeFromTermcap("F1", false, false));
+ assertKeysEquals("\033[24~", KeyHandler.getCodeFromTermcap("F2", false, false));
+ // Function keys F13-F24 (same as shifted F1-F12):
+ assertKeysEquals("\033[1;2P", KeyHandler.getCodeFromTermcap("F3", false, false));
+ assertKeysEquals("\033[1;2Q", KeyHandler.getCodeFromTermcap("F4", false, false));
+ assertKeysEquals("\033[1;2R", KeyHandler.getCodeFromTermcap("F5", false, false));
+ assertKeysEquals("\033[1;2S", KeyHandler.getCodeFromTermcap("F6", false, false));
+ assertKeysEquals("\033[15;2~", KeyHandler.getCodeFromTermcap("F7", false, false));
+ assertKeysEquals("\033[17;2~", KeyHandler.getCodeFromTermcap("F8", false, false));
+ assertKeysEquals("\033[18;2~", KeyHandler.getCodeFromTermcap("F9", false, false));
+ assertKeysEquals("\033[19;2~", KeyHandler.getCodeFromTermcap("FA", false, false));
+ assertKeysEquals("\033[20;2~", KeyHandler.getCodeFromTermcap("FB", false, false));
+ assertKeysEquals("\033[21;2~", KeyHandler.getCodeFromTermcap("FC", false, false));
+ assertKeysEquals("\033[23;2~", KeyHandler.getCodeFromTermcap("FD", false, false));
+ assertKeysEquals("\033[24;2~", KeyHandler.getCodeFromTermcap("FE", false, false));
+ }
+
+ public void testKeyCodes() {
+ // Return sends carriage return (\r), which normally gets translated by the device driver to newline (\n) unless the ICRNL termios
+ // flag has been set.
+ assertKeysEquals("\r", KeyHandler.getCode(KeyEvent.KEYCODE_ENTER, 0, false, false));
+
+ // Backspace.
+ assertKeysEquals("\u007f", KeyHandler.getCode(KeyEvent.KEYCODE_DEL, 0, false, false));
+
+ // Space.
+ assertNull(KeyHandler.getCode(KeyEvent.KEYCODE_SPACE, 0, false, false));
+ assertKeysEquals("\u0000", KeyHandler.getCode(KeyEvent.KEYCODE_SPACE, KeyHandler.KEYMOD_CTRL, false, false));
+
+ // Back tab.
+ assertKeysEquals("\033[Z", KeyHandler.getCode(KeyEvent.KEYCODE_TAB, KeyHandler.KEYMOD_SHIFT, false, false));
+
+ // Arrow keys (up/down/right/left):
+ assertKeysEquals("\033[A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, 0, false, false));
+ assertKeysEquals("\033[B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, 0, false, false));
+ assertKeysEquals("\033[C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, 0, false, false));
+ assertKeysEquals("\033[D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, 0, false, false));
+ // .. shifted:
+ assertKeysEquals("\033[1;2A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[1;2B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[1;2C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[1;2D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, KeyHandler.KEYMOD_SHIFT, false, false));
+ // .. ctrl:ed:
+ assertKeysEquals("\033[1;5A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, KeyHandler.KEYMOD_CTRL, false, false));
+ assertKeysEquals("\033[1;5B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, KeyHandler.KEYMOD_CTRL, false, false));
+ assertKeysEquals("\033[1;5C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, KeyHandler.KEYMOD_CTRL, false, false));
+ assertKeysEquals("\033[1;5D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, KeyHandler.KEYMOD_CTRL, false, false));
+ // .. ctrl:ed and shifted:
+ int mod = KeyHandler.KEYMOD_CTRL | KeyHandler.KEYMOD_SHIFT;
+ assertKeysEquals("\033[1;6A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, mod, false, false));
+ assertKeysEquals("\033[1;6B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, mod, false, false));
+ assertKeysEquals("\033[1;6C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, mod, false, false));
+ assertKeysEquals("\033[1;6D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, mod, false, false));
+
+ // Home/end keys:
+ assertKeysEquals("\033[H", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_HOME, 0, false, false));
+ assertKeysEquals("\033[F", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_END, 0, false, false));
+ // ... shifted:
+ assertKeysEquals("\033[1;2H", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_HOME, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[1;2F", KeyHandler.getCode(KeyEvent.KEYCODE_MOVE_END, KeyHandler.KEYMOD_SHIFT, false, false));
+
+ // Function keys F1-F12:
+ assertKeysEquals("\033OP", KeyHandler.getCode(KeyEvent.KEYCODE_F1, 0, false, false));
+ assertKeysEquals("\033OQ", KeyHandler.getCode(KeyEvent.KEYCODE_F2, 0, false, false));
+ assertKeysEquals("\033OR", KeyHandler.getCode(KeyEvent.KEYCODE_F3, 0, false, false));
+ assertKeysEquals("\033OS", KeyHandler.getCode(KeyEvent.KEYCODE_F4, 0, false, false));
+ assertKeysEquals("\033[15~", KeyHandler.getCode(KeyEvent.KEYCODE_F5, 0, false, false));
+ assertKeysEquals("\033[17~", KeyHandler.getCode(KeyEvent.KEYCODE_F6, 0, false, false));
+ assertKeysEquals("\033[18~", KeyHandler.getCode(KeyEvent.KEYCODE_F7, 0, false, false));
+ assertKeysEquals("\033[19~", KeyHandler.getCode(KeyEvent.KEYCODE_F8, 0, false, false));
+ assertKeysEquals("\033[20~", KeyHandler.getCode(KeyEvent.KEYCODE_F9, 0, false, false));
+ assertKeysEquals("\033[21~", KeyHandler.getCode(KeyEvent.KEYCODE_F10, 0, false, false));
+ assertKeysEquals("\033[23~", KeyHandler.getCode(KeyEvent.KEYCODE_F11, 0, false, false));
+ assertKeysEquals("\033[24~", KeyHandler.getCode(KeyEvent.KEYCODE_F12, 0, false, false));
+ // Function keys F13-F24 (same as shifted F1-F12):
+ assertKeysEquals("\033[1;2P", KeyHandler.getCode(KeyEvent.KEYCODE_F1, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[1;2Q", KeyHandler.getCode(KeyEvent.KEYCODE_F2, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[1;2R", KeyHandler.getCode(KeyEvent.KEYCODE_F3, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[1;2S", KeyHandler.getCode(KeyEvent.KEYCODE_F4, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[15;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F5, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[17;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F6, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[18;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F7, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[19;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F8, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[20;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F9, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[21;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F10, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[23;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F11, KeyHandler.KEYMOD_SHIFT, false, false));
+ assertKeysEquals("\033[24;2~", KeyHandler.getCode(KeyEvent.KEYCODE_F12, KeyHandler.KEYMOD_SHIFT, false, false));
+
+ assertKeysEquals("0", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_0, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+ assertKeysEquals("1", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_1, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+ assertKeysEquals("2", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_2, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+ assertKeysEquals("3", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_3, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+ assertKeysEquals("4", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_4, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+ assertKeysEquals("5", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_5, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+ assertKeysEquals("6", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_6, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+ assertKeysEquals("7", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_7, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+ assertKeysEquals("8", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_8, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+ assertKeysEquals("9", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_9, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+ assertKeysEquals(",", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_COMMA, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+ assertKeysEquals(".", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_DOT, KeyHandler.KEYMOD_NUM_LOCK, false, false));
+
+ assertKeysEquals("\033[2~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_0, 0, false, false));
+ assertKeysEquals("\033[F", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_1, 0, false, false));
+ assertKeysEquals("\033[B", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_2, 0, false, false));
+ assertKeysEquals("\033[6~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_3, 0, false, false));
+ assertKeysEquals("\033[D", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_4, 0, false, false));
+ assertKeysEquals("5", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_5, 0, false, false));
+ assertKeysEquals("\033[C", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_6, 0, false, false));
+ assertKeysEquals("\033[H", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_7, 0, false, false));
+ assertKeysEquals("\033[A", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_8, 0, false, false));
+ assertKeysEquals("\033[5~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_9, 0, false, false));
+ assertKeysEquals("\033[3~", KeyHandler.getCode(KeyEvent.KEYCODE_NUMPAD_DOT, 0, false, false));
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java
new file mode 100644
index 0000000..13377c0
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java
@@ -0,0 +1,196 @@
+package com.termux.terminal;
+
+import android.util.Base64;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+/** "ESC ]" is the Operating System Command. */
+public class OperatingSystemControlTest extends TerminalTestCase {
+
+ public void testSetTitle() throws Exception {
+ List expectedTitleChanges = new ArrayList<>();
+
+ withTerminalSized(10, 10);
+ enterString("\033]0;Hello, world\007");
+ assertEquals("Hello, world", mTerminal.getTitle());
+ expectedTitleChanges.add(new ChangedTitle(null, "Hello, world"));
+ assertEquals(expectedTitleChanges, mOutput.titleChanges);
+
+ enterString("\033]0;Goodbye, world\007");
+ assertEquals("Goodbye, world", mTerminal.getTitle());
+ expectedTitleChanges.add(new ChangedTitle("Hello, world", "Goodbye, world"));
+ assertEquals(expectedTitleChanges, mOutput.titleChanges);
+
+ enterString("\033]0;Goodbye, \u00F1 world\007");
+ assertEquals("Goodbye, \uu00F1 world", mTerminal.getTitle());
+ expectedTitleChanges.add(new ChangedTitle("Goodbye, world", "Goodbye, \uu00F1 world"));
+ assertEquals(expectedTitleChanges, mOutput.titleChanges);
+
+ // 2 should work as well (0 sets both title and icon).
+ enterString("\033]2;Updated\007");
+ assertEquals("Updated", mTerminal.getTitle());
+ expectedTitleChanges.add(new ChangedTitle("Goodbye, \uu00F1 world", "Updated"));
+ assertEquals(expectedTitleChanges, mOutput.titleChanges);
+
+ enterString("\033[22;0t");
+ enterString("\033]0;FIRST\007");
+ expectedTitleChanges.add(new ChangedTitle("Updated", "FIRST"));
+ assertEquals("FIRST", mTerminal.getTitle());
+ assertEquals(expectedTitleChanges, mOutput.titleChanges);
+
+ enterString("\033[22;0t");
+ enterString("\033]0;SECOND\007");
+ assertEquals("SECOND", mTerminal.getTitle());
+
+ expectedTitleChanges.add(new ChangedTitle("FIRST", "SECOND"));
+ assertEquals(expectedTitleChanges, mOutput.titleChanges);
+
+ enterString("\033[23;0t");
+ assertEquals("FIRST", mTerminal.getTitle());
+
+ expectedTitleChanges.add(new ChangedTitle("SECOND", "FIRST"));
+ assertEquals(expectedTitleChanges, mOutput.titleChanges);
+
+ enterString("\033[23;0t");
+ expectedTitleChanges.add(new ChangedTitle("FIRST", "Updated"));
+ assertEquals(expectedTitleChanges, mOutput.titleChanges);
+
+ enterString("\033[22;0t");
+ enterString("\033[22;0t");
+ enterString("\033[22;0t");
+ // Popping to same title should not cause changes.
+ enterString("\033[23;0t");
+ enterString("\033[23;0t");
+ enterString("\033[23;0t");
+ assertEquals(expectedTitleChanges, mOutput.titleChanges);
+ }
+
+ public void testTitleStack() throws Exception {
+ // echo -ne '\e]0;BEFORE\007' # set title
+ // echo -ne '\e[22t' # push to stack
+ // echo -ne '\e]0;AFTER\007' # set new title
+ // echo -ne '\e[23t' # retrieve from stack
+
+ withTerminalSized(10, 10);
+ enterString("\033]0;InitialTitle\007");
+ assertEquals("InitialTitle", mTerminal.getTitle());
+ enterString("\033[22t");
+ assertEquals("InitialTitle", mTerminal.getTitle());
+ enterString("\033]0;UpdatedTitle\007");
+ assertEquals("UpdatedTitle", mTerminal.getTitle());
+ enterString("\033[23t");
+ assertEquals("InitialTitle", mTerminal.getTitle());
+ enterString("\033[23t\033[23t\033[23t");
+ assertEquals("InitialTitle", mTerminal.getTitle());
+ }
+
+ public void testSetColor() throws Exception {
+ // "OSC 4; $INDEX; $COLORSPEC BEL" => Change color $INDEX to the color specified by $COLORSPEC.
+ withTerminalSized(4, 4).enterString("\033]4;5;#00FF00\007");
+ assertEquals(Integer.toHexString(0xFF00FF00), Integer.toHexString(mTerminal.mColors.mCurrentColors[5]));
+ enterString("\033]4;5;#00FFAB\007");
+ assertEquals(mTerminal.mColors.mCurrentColors[5], 0xFF00FFAB);
+ enterString("\033]4;255;#ABFFAB\007");
+ assertEquals(mTerminal.mColors.mCurrentColors[255], 0xFFABFFAB);
+ // Two indexed colors at once:
+ enterString("\033]4;7;#00FF00;8;#0000FF\007");
+ assertEquals(mTerminal.mColors.mCurrentColors[7], 0xFF00FF00);
+ assertEquals(mTerminal.mColors.mCurrentColors[8], 0xFF0000FF);
+ }
+
+ void assertIndexColorsMatch(int[] expected) {
+ for (int i = 0; i < 255; i++)
+ assertEquals("index=" + i, expected[i], mTerminal.mColors.mCurrentColors[i]);
+ }
+
+ public void testResetColor() throws Exception {
+ withTerminalSized(4, 4);
+ int[] initialColors = new int[TextStyle.NUM_INDEXED_COLORS];
+ System.arraycopy(mTerminal.mColors.mCurrentColors, 0, initialColors, 0, initialColors.length);
+ int[] expectedColors = new int[initialColors.length];
+ System.arraycopy(mTerminal.mColors.mCurrentColors, 0, expectedColors, 0, expectedColors.length);
+ Random rand = new Random();
+ for (int endType = 0; endType < 3; endType++) {
+ // Both BEL (7) and ST (ESC \) can end an OSC sequence.
+ String ender = (endType == 0) ? "\007" : "\033\\";
+ for (int i = 0; i < 255; i++) {
+ expectedColors[i] = 0xFF000000 + (rand.nextInt() & 0xFFFFFF);
+ int r = (expectedColors[i] >> 16) & 0xFF;
+ int g = (expectedColors[i] >> 8) & 0xFF;
+ int b = expectedColors[i] & 0xFF;
+ String rgbHex = String.format("%02x", r) + String.format("%02x", g) + String.format("%02x", b);
+ enterString("\033]4;" + i + ";#" + rgbHex + ender);
+ assertEquals(expectedColors[i], mTerminal.mColors.mCurrentColors[i]);
+ }
+ }
+
+ enterString("\033]104;0\007");
+ expectedColors[0] = TerminalColors.COLOR_SCHEME.mDefaultColors[0];
+ assertIndexColorsMatch(expectedColors);
+ enterString("\033]104;1;2\007");
+ expectedColors[1] = TerminalColors.COLOR_SCHEME.mDefaultColors[1];
+ expectedColors[2] = TerminalColors.COLOR_SCHEME.mDefaultColors[2];
+ assertIndexColorsMatch(expectedColors);
+ enterString("\033]104\007"); // Reset all colors.
+ assertIndexColorsMatch(TerminalColors.COLOR_SCHEME.mDefaultColors);
+ }
+
+ public void disabledTestSetClipboard() {
+ // Cannot run this as a unit test since Base64 is a android.util class.
+ enterString("\033]52;c;" + Base64.encodeToString("Hello, world".getBytes(), 0) + "\007");
+ }
+
+ public void testResettingTerminalResetsColor() throws Exception {
+ // "OSC 4; $INDEX; $COLORSPEC BEL" => Change color $INDEX to the color specified by $COLORSPEC.
+ withTerminalSized(4, 4).enterString("\033]4;5;#00FF00\007");
+ enterString("\033]4;5;#00FFAB\007").assertColor(5, 0xFF00FFAB);
+ enterString("\033]4;255;#ABFFAB\007").assertColor(255, 0xFFABFFAB);
+ mTerminal.reset();
+ assertIndexColorsMatch(TerminalColors.COLOR_SCHEME.mDefaultColors);
+ }
+
+ public void testSettingDynamicColors() {
+ // "${OSC}${DYNAMIC};${COLORSPEC}${BEL_OR_STRINGTERMINATOR}" => Change ${DYNAMIC} color to the color specified by $COLORSPEC where:
+ // DYNAMIC=10: Text foreground color.
+ // DYNAMIC=11: Text background color.
+ // DYNAMIC=12: Text cursor color.
+ withTerminalSized(3, 3).enterString("\033]10;#ABCD00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFABCD00);
+ enterString("\033]11;#0ABCD0\007").assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF0ABCD0);
+ enterString("\033]12;#00ABCD\007").assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00ABCD);
+ // Two special colors at once
+ // ("Each successive parameter changes the next color in the list. The value of P s tells the starting point in the list"):
+ enterString("\033]10;#FF0000;#00FF00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000);
+ assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF00FF00);
+ // Three at once:
+ enterString("\033]10;#0000FF;#00FF00;#FF0000\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFF0000FF);
+ assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFF00FF00).assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFFFF0000);
+
+ // Without ending semicolon:
+ enterString("\033]10;#FF0000\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000);
+ // For background and cursor:
+ enterString("\033]11;#FFFF00;\007").assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFFFFFF00);
+ enterString("\033]12;#00FFFF;\007").assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00FFFF);
+
+ // Using string terminator:
+ String stringTerminator = "\033\\";
+ enterString("\033]10;#FF0000" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFFF0000);
+ // For background and cursor:
+ enterString("\033]11;#FFFF00;" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_BACKGROUND, 0xFFFFFF00);
+ enterString("\033]12;#00FFFF;" + stringTerminator).assertColor(TextStyle.COLOR_INDEX_CURSOR, 0xFF00FFFF);
+ }
+
+ public void testReportSpecialColors() {
+ // "${OSC}${DYNAMIC};?${BEL}" => Terminal responds with the control sequence which would set the current color.
+ // Both xterm and libvte (gnome-terminal and others) use the longest color representation, which means that
+ // the response is "${OSC}rgb:RRRR/GGGG/BBBB"
+ withTerminalSized(3, 3).enterString("\033]10;#ABCD00\007").assertColor(TextStyle.COLOR_INDEX_FOREGROUND, 0xFFABCD00);
+ assertEnteringStringGivesResponse("\033]10;?\007", "\033]10;rgb:abab/cdcd/0000\007");
+ // Same as above but with string terminator. xterm uses the same string terminator in the response, which
+ // e.g. script posted at http://superuser.com/questions/157563/programmatic-access-to-current-xterm-background-color
+ // relies on:
+ assertEnteringStringGivesResponse("\033]10;?\033\\", "\033]10;rgb:abab/cdcd/0000\033\\");
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/RectangularAreasTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/RectangularAreasTest.java
new file mode 100644
index 0000000..8709c2c
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/RectangularAreasTest.java
@@ -0,0 +1,117 @@
+package com.termux.terminal;
+
+public class RectangularAreasTest extends TerminalTestCase {
+
+ /** http://www.vt100.net/docs/vt510-rm/DECFRA */
+ public void testFillRectangularArea() {
+ withTerminalSized(3, 3).enterString("\033[88$x").assertLinesAre("XXX", "XXX", "XXX");
+ withTerminalSized(3, 3).enterString("\033[88;1;1;2;10$x").assertLinesAre("XXX", "XXX", " ");
+ withTerminalSized(3, 3).enterString("\033[88;2;1;3;10$x").assertLinesAre(" ", "XXX", "XXX");
+ withTerminalSized(3, 3).enterString("\033[88;1;1;100;1$x").assertLinesAre("X ", "X ", "X ");
+ withTerminalSized(3, 3).enterString("\033[88;1;1;100;2$x").assertLinesAre("XX ", "XX ", "XX ");
+ withTerminalSized(3, 3).enterString("\033[88;100;1;100;2$x").assertLinesAre(" ", " ", " ");
+ }
+
+ /** http://www.vt100.net/docs/vt510-rm/DECERA */
+ public void testEraseRectangularArea() {
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[$z").assertLinesAre(" ", " ", " ");
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;10$z").assertLinesAre(" ", " ", "GHI");
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;1;3;10$z").assertLinesAre("ABC", " ", " ");
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;1$z").assertLinesAre(" BC", " EF", " HI");
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;2$z").assertLinesAre(" C", " F", " I");
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[100;1;100;2$z").assertLinesAre("ABC", "DEF", "GHI");
+
+ withTerminalSized(3, 3).enterString("A\033[$zBC").assertLinesAre(" BC", " ", " ");
+ }
+
+ /** http://www.vt100.net/docs/vt510-rm/DECSED */
+ public void testSelectiveEraseInDisplay() {
+ // ${CSI}1"q enables protection, ${CSI}0"q disables it.
+ // ${CSI}?${0,1,2}J" erases (0=cursor to end, 1=start to cursor, 2=complete display).
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[?2J").assertLinesAre(" ", " ", " ");
+ withTerminalSized(3, 3).enterString("ABC\033[1\"qDE\033[0\"qFGHI\033[?2J").assertLinesAre(" ", "DE ", " ");
+ withTerminalSized(3, 3).enterString("\033[1\"qABCDE\033[0\"qFGHI\033[?2J").assertLinesAre("ABC", "DE ", " ");
+ }
+
+ /** http://vt100.net/docs/vt510-rm/DECSEL */
+ public void testSelectiveEraseInLine() {
+ // ${CSI}1"q enables protection, ${CSI}0"q disables it.
+ // ${CSI}?${0,1,2}K" erases (0=cursor to end, 1=start to cursor, 2=complete line).
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[?2K").assertLinesAre("ABC", "DEF", " ");
+ withTerminalSized(3, 3).enterString("ABCDE\033[?0KFGHI").assertLinesAre("ABC", "DEF", "GHI");
+ withTerminalSized(3, 3).enterString("ABCDE\033[?1KFGHI").assertLinesAre("ABC", " F", "GHI");
+ withTerminalSized(3, 3).enterString("ABCDE\033[?2KFGHI").assertLinesAre("ABC", " F", "GHI");
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;2H\033[?0K").assertLinesAre("ABC", "D ", "GHI");
+ withTerminalSized(3, 3).enterString("ABC\033[1\"qD\033[0\"qE\033[?2KFGHI").assertLinesAre("ABC", "D F", "GHI");
+ }
+
+ /** http://www.vt100.net/docs/vt510-rm/DECSERA */
+ public void testSelectiveEraseInRectangle() {
+ // ${CSI}1"q enables protection, ${CSI}0"q disables it.
+ // ${CSI}?${TOP};${LEFT};${BOTTOM};${RIGHT}${" erases.
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[${").assertLinesAre(" ", " ", " ");
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;10${").assertLinesAre(" ", " ", "GHI");
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[2;1;3;10${").assertLinesAre("ABC", " ", " ");
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;1${").assertLinesAre(" BC", " EF", " HI");
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;100;2${").assertLinesAre(" C", " F", " I");
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[100;1;100;2${").assertLinesAre("ABC", "DEF", "GHI");
+
+ withTerminalSized(3, 3).enterString("ABCD\033[1\"qE\033[0\"qFGHI\033[${").assertLinesAre(" ", " E ", " ");
+ withTerminalSized(3, 3).enterString("ABCD\033[1\"qE\033[0\"qFGHI\033[1;1;2;10${").assertLinesAre(" ", " E ", "GHI");
+ }
+
+ /** http://vt100.net/docs/vt510-rm/DECCRA */
+ public void testRectangularCopy() {
+ // "${CSI}${SRC_TOP};${SRC_LEFT};${SRC_BOTTOM};${SRC_RIGHT};${SRC_PAGE};${DST_TOP};${DST_LEFT};${DST_PAGE}\$v"
+ withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;2;2;1;2;5;1$v").assertLinesAre("ABC ", "DEF AB ", "GHI DE ");
+ withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;3;3;1;1;4;1$v").assertLinesAre("ABCABC ", "DEFDEF ", "GHIGHI ");
+ withTerminalSized(7, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;3;3;1;1;3;1$v").assertLinesAre("ABABC ", "DEDEF ", "GHGHI ");
+ withTerminalSized(7, 3).enterString(" ABC\r\n DEF\r\n GHI\033[1;4;3;6;1;1;1;1$v").assertLinesAre("ABCABC ", "DEFDEF ",
+ "GHIGHI ");
+ withTerminalSized(7, 3).enterString(" ABC\r\n DEF\r\n GHI\033[1;4;3;6;1;1;2;1$v").assertLinesAre(" ABCBC ", " DEFEF ",
+ " GHIHI ");
+ withTerminalSized(3, 3).enterString("ABC\r\nDEF\r\nGHI\033[1;1;2;2;1;2;2;1$v").assertLinesAre("ABC", "DAB", "GDE");
+
+ // Enable ${CSI}?6h origin mode (DECOM) and ${CSI}?69h for left/right margin (DECLRMM) enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s
+ // for DECSLRM margin setting.
+ withTerminalSized(5, 5).enterString("\033[?6h\033[?69h\033[2;4s");
+ enterString("ABCDEFGHIJK").assertLinesAre(" ABC ", " DEF ", " GHI ", " JK ", " ");
+ enterString("\033[1;1;2;2;1;2;2;1$v").assertLinesAre(" ABC ", " DAB ", " GDE ", " JK ", " ");
+ }
+
+ /** http://vt100.net/docs/vt510-rm/DECCARA */
+ public void testChangeAttributesInRectangularArea() {
+ final int b = TextStyle.CHARACTER_ATTRIBUTE_BOLD;
+ // "${CSI}${TOP};${LEFT};${BOTTOM};${RIGHT};${ATTRIBUTES}\$r"
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;2;1$r").assertLinesAre("ABC", "DEF", "GHI");
+ assertEffectAttributesSet(effectLine(b, b, b), effectLine(b, b, 0), effectLine(0, 0, 0));
+
+ // Now with http://www.vt100.net/docs/vt510-rm/DECSACE ("${CSI}2*x") specifying rectangle:
+ withTerminalSized(3, 3).enterString("\033[2*xABCDEFGHI\033[1;1;2;2;1$r").assertLinesAre("ABC", "DEF", "GHI");
+ assertEffectAttributesSet(effectLine(b, b, 0), effectLine(b, b, 0), effectLine(0, 0, 0));
+ }
+
+ /** http://vt100.net/docs/vt510-rm/DECCARA */
+ public void testReverseAttributesInRectangularArea() {
+ final int b = TextStyle.CHARACTER_ATTRIBUTE_BOLD;
+ final int u = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ final int bu = TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ // "${CSI}${TOP};${LEFT};${BOTTOM};${RIGHT};${ATTRIBUTES}\$t"
+ withTerminalSized(3, 3).enterString("ABCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI");
+ assertEffectAttributesSet(effectLine(b, b, b), effectLine(b, b, 0), effectLine(0, 0, 0));
+
+ // Now with http://www.vt100.net/docs/vt510-rm/DECSACE ("${CSI}2*x") specifying rectangle:
+ withTerminalSized(3, 3).enterString("\033[2*xABCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI");
+ assertEffectAttributesSet(effectLine(b, b, 0), effectLine(b, b, 0), effectLine(0, 0, 0));
+
+ // Check reversal by initially bolding the B:
+ withTerminalSized(3, 3).enterString("\033[2*xA\033[1mB\033[0mCDEFGHI\033[1;1;2;2;1$t").assertLinesAre("ABC", "DEF", "GHI");
+ assertEffectAttributesSet(effectLine(b, 0, 0), effectLine(b, b, 0), effectLine(0, 0, 0));
+
+ // Check reversal by initially underlining A, bolding B, then reversing both bold and underline:
+ withTerminalSized(3, 3).enterString("\033[2*x\033[4mA\033[0m\033[1mB\033[0mCDEFGHI\033[1;1;2;2;1;4$t").assertLinesAre("ABC", "DEF",
+ "GHI");
+ assertEffectAttributesSet(effectLine(b, u, 0), effectLine(bu, bu, 0), effectLine(0, 0, 0));
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/ResizeTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/ResizeTest.java
new file mode 100644
index 0000000..6c32d66
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/ResizeTest.java
@@ -0,0 +1,212 @@
+package com.termux.terminal;
+
+public class ResizeTest extends TerminalTestCase {
+
+ public void testResizeWhenHasHistory() {
+ final int cols = 3;
+ withTerminalSized(cols, 3).enterString("111222333444555666777888999").assertCursorAt(2, 2).assertLinesAre("777", "888", "999");
+ resize(cols, 5).assertCursorAt(4, 2).assertLinesAre("555", "666", "777", "888", "999");
+ resize(cols, 3).assertCursorAt(2, 2).assertLinesAre("777", "888", "999");
+ }
+
+ public void testResizeWhenInAltBuffer() {
+ final int rows = 3, cols = 3;
+ withTerminalSized(cols, rows).enterString("a\r\ndef$").assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1);
+
+ // Resize and back again while in main buffer:
+ resize(cols, 5).assertLinesAre("a ", "def", "$ ", " ", " ").assertCursorAt(2, 1);
+ resize(cols, rows).assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1);
+
+ // Switch to alt buffer:
+ enterString("\033[?1049h").assertLinesAre(" ", " ", " ").assertCursorAt(2, 1);
+ enterString("h").assertLinesAre(" ", " ", " h ").assertCursorAt(2, 2);
+
+ resize(cols, 5).resize(cols, rows);
+
+ // Switch from alt buffer:
+ enterString("\033[?1049l").assertLinesAre("a ", "def", "$ ").assertCursorAt(2, 1);
+ }
+
+ public void testShrinkingInAltBuffer() {
+ final int rows = 5;
+ final int cols = 3;
+ withTerminalSized(cols, rows).enterString("A\r\nB\r\nC\r\nD\r\nE").assertLinesAre("A ", "B ", "C ", "D ", "E ");
+ enterString("\033[?1049h").assertLinesAre(" ", " ", " ", " ", " ");
+ resize(3, 3).enterString("\033[?1049lF").assertLinesAre("C ", "D ", "EF ");
+ }
+
+ public void testResizeAfterNewlineWhenInAltBuffer() {
+ final int rows = 3;
+ final int cols = 3;
+ withTerminalSized(cols, rows);
+ enterString("a\r\nb\r\nc\r\nd\r\ne\r\nf\r\n").assertLinesAre("e ", "f ", " ").assertCursorAt(2, 0);
+ assertLineWraps(false, false, false);
+
+ // Switch to alt buffer:
+ enterString("\033[?1049h").assertLinesAre(" ", " ", " ").assertCursorAt(2, 0);
+ enterString("h").assertLinesAre(" ", " ", "h ").assertCursorAt(2, 1);
+
+ // Grow by two rows:
+ resize(cols, 5).assertLinesAre(" ", " ", "h ", " ", " ").assertCursorAt(2, 1);
+ resize(cols, rows).assertLinesAre(" ", " ", "h ").assertCursorAt(2, 1);
+
+ // Switch from alt buffer:
+ enterString("\033[?1049l").assertLinesAre("e ", "f ", " ").assertCursorAt(2, 0);
+ }
+
+ public void testResizeAfterHistoryWraparound() {
+ final int rows = 3;
+ final int cols = 10;
+ withTerminalSized(cols, rows);
+ StringBuilder buffer = new StringBuilder();
+ for (int i = 0; i < 1000; i++) {
+ String s = Integer.toString(i);
+ enterString(s);
+ buffer.setLength(0);
+ buffer.append(s);
+ while (buffer.length() < cols)
+ buffer.append(' ');
+ if (i > rows) {
+ assertLineIs(rows - 1, buffer.toString());
+ }
+ enterString("\r\n");
+ }
+ assertLinesAre("998 ", "999 ", " ");
+ resize(cols, 2);
+ assertLinesAre("999 ", " ");
+ resize(cols, 5);
+ assertLinesAre("996 ", "997 ", "998 ", "999 ", " ");
+ resize(cols, rows);
+ assertLinesAre("998 ", "999 ", " ");
+ }
+
+ public void testVerticalResize() {
+ final int rows = 5;
+ final int cols = 3;
+
+ withTerminalSized(cols, rows);
+ // Foreground color to 119:
+ enterString("\033[38;5;119m");
+ // Background color to 129:
+ enterString("\033[48;5;129m");
+ // Clear with ED, Erase in Display:
+ enterString("\033[2J");
+ for (int r = 0; r < rows; r++) {
+ for (int c = 0; c < cols; c++) {
+ long style = getStyleAt(r, c);
+ assertEquals(119, TextStyle.decodeForeColor(style));
+ assertEquals(129, TextStyle.decodeBackColor(style));
+ }
+ }
+ enterString("11\r\n22");
+ assertLinesAre("11 ", "22 ", " ", " ", " ").assertLineWraps(false, false, false, false, false);
+ resize(cols, rows - 2).assertLinesAre("11 ", "22 ", " ");
+
+ // After resize, screen should still be same color:
+ for (int r = 0; r < rows - 2; r++) {
+ for (int c = 0; c < cols; c++) {
+ long style = getStyleAt(r, c);
+ assertEquals(119, TextStyle.decodeForeColor(style));
+ assertEquals(129, TextStyle.decodeBackColor(style));
+ }
+ }
+
+ // Background color to 200 and grow back size (which should be cleared to the new background color):
+ enterString("\033[48;5;200m");
+ resize(cols, rows);
+ for (int r = 0; r < rows; r++) {
+ for (int c = 0; c < cols; c++) {
+ long style = getStyleAt(r, c);
+ assertEquals(119, TextStyle.decodeForeColor(style));
+ assertEquals("wrong at row=" + r, r >= 3 ? 200 : 129, TextStyle.decodeBackColor(style));
+ }
+ }
+ }
+
+ public void testHorizontalResize() {
+ final int rows = 5;
+ final int cols = 5;
+
+ withTerminalSized(cols, rows);
+ // Background color to 129:
+ // enterString("\033[48;5;129m").assertLinesAre(" ", " ", " ", " ", " ");
+ enterString("1111\r\n2222\r\n3333\r\n4444\r\n5555").assertCursorAt(4, 4);
+ // assertEquals(129, TextStyle.decodeBackColor(getStyleAt(2, 2)));
+ assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertLineWraps(false, false, false, false, false);
+ resize(cols + 2, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4);
+ assertLineWraps(false, false, false, false, false);
+ resize(cols, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4);
+ assertLineWraps(false, false, false, false, false);
+ resize(cols - 1, rows).assertLinesAre("2222", "3333", "4444", "5555", " ").assertCursorAt(4, 0);
+ assertLineWraps(false, false, false, true, false);
+ resize(cols - 2, rows).assertLinesAre("3 ", "444", "4 ", "555", "5 ").assertCursorAt(4, 1);
+ assertLineWraps(false, true, false, true, false);
+ // Back to original size:
+ resize(cols, rows).assertLinesAre("1111 ", "2222 ", "3333 ", "4444 ", "5555 ").assertCursorAt(4, 4);
+ assertLineWraps(false, false, false, false, false);
+ }
+
+ public void testLineWrap() {
+ final int rows = 3, cols = 5;
+ withTerminalSized(cols, rows).enterString("111111").assertLinesAre("11111", "1 ", " ");
+ assertCursorAt(1, 1).assertLineWraps(true, false, false);
+
+ resize(7, rows).assertCursorAt(0, 6).assertLinesAre("111111 ", " ", " ").assertLineWraps(false, false, false);
+ resize(cols, rows).assertCursorAt(1, 1).assertLinesAre("11111", "1 ", " ").assertLineWraps(true, false, false);
+
+ enterString("2").assertLinesAre("11111", "12 ", " ").assertLineWraps(true, false, false);
+ enterString("123").assertLinesAre("11111", "12123", " ").assertLineWraps(true, false, false);
+ enterString("W").assertLinesAre("11111", "12123", "W ").assertLineWraps(true, true, false);
+
+ withTerminalSized(cols, rows).enterString("1234512345");
+ assertLinesAre("12345", "12345", " ").assertLineWraps(true, false, false);
+ enterString("W").assertLinesAre("12345", "12345", "W ").assertLineWraps(true, true, false);
+ }
+
+ public void testCursorPositionWhenShrinking() {
+ final int rows = 5, cols = 3;
+ withTerminalSized(cols, rows).enterString("$ ").assertLinesAre("$ ", " ", " ", " ", " ").assertCursorAt(0, 2);
+ resize(3, 3).assertLinesAre("$ ", " ", " ").assertCursorAt(0, 2);
+ resize(cols, rows).assertLinesAre("$ ", " ", " ", " ", " ").assertCursorAt(0, 2);
+ }
+
+ public void testResizeWithCombiningCharInLastColumn() {
+ withTerminalSized(3, 3).enterString("ABC\u0302DEF").assertLinesAre("ABC\u0302", "DEF", " ");
+ resize(4, 3).assertLinesAre("ABC\u0302D", "EF ", " ");
+
+ // Same as above but with colors:
+ withTerminalSized(3, 3).enterString("\033[37mA\033[35mB\033[33mC\u0302\033[32mD\033[31mE\033[34mF").assertLinesAre("ABC\u0302",
+ "DEF", " ");
+ resize(4, 3).assertLinesAre("ABC\u0302D", "EF ", " ");
+ assertForegroundIndices(effectLine(7, 5, 3, 2), effectLine(1, 4, 4, 4), effectLine(4, 4, 4, 4));
+ }
+
+ public void testResizeWithLineWrappingContinuing() {
+ withTerminalSized(5, 3).enterString("\r\nAB DE").assertLinesAre(" ", "AB DE", " ");
+ resize(4, 3).assertLinesAre("AB D", "E ", " ");
+ resize(3, 3).assertLinesAre("AB ", "DE ", " ");
+ resize(5, 3).assertLinesAre(" ", "AB DE", " ");
+ }
+
+ public void testResizeWithWideChars() {
+ final int rows = 3, cols = 4;
+ String twoCharsWidthOne = new String(Character.toChars(TerminalRowTest.TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1));
+ withTerminalSized(cols, rows).enterString(twoCharsWidthOne).enterString("\r\n");
+ enterString(twoCharsWidthOne).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + " ", " ");
+ resize(3, 3).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + " ", " ");
+ enterString(twoCharsWidthOne).assertLinesAre(twoCharsWidthOne + " ", twoCharsWidthOne + twoCharsWidthOne + " ", " ");
+ }
+
+ public void testResizeWithMoreWideChars() {
+ final int rows = 4, cols = 5;
+
+ withTerminalSized(cols, rows).enterString("qqrr").assertLinesAre("qqrr ", " ", " ", " ");
+ resize(2, rows).assertLinesAre("qq", "rr", " ", " ");
+ resize(5, rows).assertLinesAre("qqrr ", " ", " ", " ");
+
+ withTerminalSized(cols, rows).enterString("QR").assertLinesAre("QR ", " ", " ", " ");
+ resize(2, rows).assertLinesAre("Q", "R", " ", " ");
+ resize(5, rows).assertLinesAre("QR ", " ", " ", " ");
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/ScreenBufferTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/ScreenBufferTest.java
new file mode 100644
index 0000000..9a8f115
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/ScreenBufferTest.java
@@ -0,0 +1,65 @@
+package com.termux.terminal;
+
+public class ScreenBufferTest extends TerminalTestCase {
+
+ public void testBasics() {
+ TerminalBuffer screen = new TerminalBuffer(5, 3, 3);
+ assertEquals("", screen.getTranscriptText());
+ screen.setChar(0, 0, 'a', 0);
+ assertEquals("a", screen.getTranscriptText());
+ screen.setChar(0, 0, 'b', 0);
+ assertEquals("b", screen.getTranscriptText());
+ screen.setChar(2, 0, 'c', 0);
+ assertEquals("b c", screen.getTranscriptText());
+ screen.setChar(2, 2, 'f', 0);
+ assertEquals("b c\n\n f", screen.getTranscriptText());
+ screen.blockSet(0, 0, 2, 2, 'X', 0);
+ }
+
+ public void testBlockSet() {
+ TerminalBuffer screen = new TerminalBuffer(5, 3, 3);
+ screen.blockSet(0, 0, 2, 2, 'X', 0);
+ assertEquals("XX\nXX", screen.getTranscriptText());
+ screen.blockSet(1, 1, 2, 2, 'Y', 0);
+ assertEquals("XX\nXYY\n YY", screen.getTranscriptText());
+ }
+
+ public void testGetSelectedText() {
+ withTerminalSized(5, 3).enterString("ABCDEFGHIJ").assertLinesAre("ABCDE", "FGHIJ", " ");
+ assertEquals("AB", mTerminal.getSelectedText(0, 0, 1, 0));
+ assertEquals("BC", mTerminal.getSelectedText(1, 0, 2, 0));
+ assertEquals("CDE", mTerminal.getSelectedText(2, 0, 4, 0));
+ assertEquals("FG", mTerminal.getSelectedText(0, 1, 1, 1));
+ assertEquals("GH", mTerminal.getSelectedText(1, 1, 2, 1));
+ assertEquals("HIJ", mTerminal.getSelectedText(2, 1, 4, 1));
+
+ assertEquals("ABCDEFG", mTerminal.getSelectedText(0, 0, 1, 1));
+ withTerminalSized(5, 3).enterString("ABCDE\r\nFGHIJ").assertLinesAre("ABCDE", "FGHIJ", " ");
+ assertEquals("ABCDE\nFG", mTerminal.getSelectedText(0, 0, 1, 1));
+ }
+
+ public void testGetSelectedTextJoinFullLines() {
+ withTerminalSized(5, 3).enterString("ABCDE\r\nFG");
+ assertEquals("ABCDEFG", mTerminal.getScreen().getSelectedText(0, 0, 1, 1, true, true));
+
+ withTerminalSized(5, 3).enterString("ABC\r\nFG");
+ assertEquals("ABC\nFG", mTerminal.getScreen().getSelectedText(0, 0, 1, 1, true, true));
+ }
+
+ public void testGetWordAtLocation() {
+ withTerminalSized(5, 3).enterString("ABCDEFGHIJ\r\nKLMNO");
+ assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(0, 0));
+ assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(4, 1));
+ assertEquals("ABCDEFGHIJKLMNO", mTerminal.getScreen().getWordAtLocation(4, 2));
+
+ withTerminalSized(5, 3).enterString("ABC DEF GHI ");
+ assertEquals("ABC", mTerminal.getScreen().getWordAtLocation(0, 0));
+ assertEquals("", mTerminal.getScreen().getWordAtLocation(3, 0));
+ assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(4, 0));
+ assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(0, 1));
+ assertEquals("DEF", mTerminal.getScreen().getWordAtLocation(1, 1));
+ assertEquals("GHI", mTerminal.getScreen().getWordAtLocation(0, 2));
+ assertEquals("", mTerminal.getScreen().getWordAtLocation(1, 2));
+ assertEquals("", mTerminal.getScreen().getWordAtLocation(2, 2));
+ }
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java
new file mode 100644
index 0000000..039605a
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java
@@ -0,0 +1,167 @@
+package com.termux.terminal;
+
+/**
+ * ${CSI}${top};${bottom}r" - set Scrolling Region [top;bottom] (default = full size of window) (DECSTBM).
+ *
+ * "DECSTBM moves the cursor to column 1, line 1 of the page" (http://www.vt100.net/docs/vt510-rm/DECSTBM).
+ */
+public class ScrollRegionTest extends TerminalTestCase {
+
+ public void testScrollRegionTop() {
+ withTerminalSized(3, 4).enterString("111222333444").assertLinesAre("111", "222", "333", "444");
+ enterString("\033[2r").assertCursorAt(0, 0);
+ enterString("\r\n\r\n\r\n\r\nCDEFGH").assertLinesAre("111", "444", "CDE", "FGH").assertHistoryStartsWith("333");
+ enterString("IJK").assertLinesAre("111", "CDE", "FGH", "IJK").assertHistoryStartsWith("444");
+ // Reset scroll region and enter line:
+ enterString("\033[r").enterString("\r\n\r\n\r\n").enterString("LMNOPQ").assertLinesAre("CDE", "FGH", "LMN", "OPQ");
+ }
+
+ public void testScrollRegionBottom() {
+ withTerminalSized(3, 4).enterString("111222333444");
+ assertLinesAre("111", "222", "333", "444");
+ enterString("\033[1;3r").assertCursorAt(0, 0);
+ enterString("\r\n\r\nCDEFGH").assertLinesAre("222", "CDE", "FGH", "444").assertHistoryStartsWith("111");
+ // Reset scroll region and enter line:
+ enterString("\033[r").enterString("\r\n\r\n\r\n").enterString("IJKLMN").assertLinesAre("CDE", "FGH", "IJK", "LMN");
+ }
+
+ public void testScrollRegionResetWithOriginMode() {
+ withTerminalSized(3, 4).enterString("111222333444");
+ assertLinesAre("111", "222", "333", "444");
+ // "\033[?6h" sets origin mode, so that the later DECSTBM resets cursor to below margin:
+ enterString("\033[?6h\033[2r").assertCursorAt(1, 0);
+ }
+
+ public void testScrollRegionLeft() {
+ // ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting.
+ withTerminalSized(3, 3).enterString("\033[?69h\033[2sABCDEFG").assertLinesAre("ABC", " DE", " FG");
+ enterString("HI").assertLinesAre("ADE", " FG", " HI").enterString("JK").assertLinesAre("AFG", " HI", " JK");
+ enterString("\n").assertLinesAre("AHI", " JK", " ");
+ }
+
+ public void testScrollRegionRight() {
+ // ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting.
+ withTerminalSized(3, 3).enterString("YYY\033[?69h\033[1;2sABCDEF").assertLinesAre("ABY", "CD ", "EF ");
+ enterString("GH").assertLinesAre("CDY", "EF ", "GH ").enterString("IJ").assertLinesAre("EFY", "GH ", "IJ ");
+ enterString("\n").assertLinesAre("GHY", "IJ ", " ");
+ }
+
+ public void testScrollRegionOnAllSides() {
+ // ${CSI}?69h for DECLRMM enabling, ${CSI}${LEFTMARGIN};${RIGHTMARGIN}s for DECSLRM margin setting.
+ withTerminalSized(4, 4).enterString("ABCDEFGHIJKLMNOP").assertLinesAre("ABCD", "EFGH", "IJKL", "MNOP");
+ // http://www.vt100.net/docs/vt510-rm/DECOM
+ enterString("\033[?6h\033[2;3r").assertCursorAt(1, 0);
+ enterString("\033[?69h\033[2;3s").assertCursorAt(1, 1);
+ enterString("QRST").assertLinesAre("ABCD", "EQRH", "ISTL", "MNOP");
+ enterString("UV").assertLinesAre("ABCD", "ESTH", "IUVL", "MNOP");
+ }
+
+ public void testDECCOLMResetsScrollMargin() {
+ // DECCOLM — Select 80 or 132 Columns per Page (http://www.vt100.net/docs/vt510-rm/DECCOLM) has the important
+ // side effect to clear scroll margins, which is useful for e.g. the "reset" utility to clear scroll margins.
+ withTerminalSized(3, 4).enterString("111222333444").assertLinesAre("111", "222", "333", "444");
+ enterString("\033[2r\033[?3h\r\nABCDEFGHIJKL").assertLinesAre("ABC", "DEF", "GHI", "JKL");
+ }
+
+ public void testScrollOutsideVerticalRegion() {
+ withTerminalSized(3, 4).enterString("\033[0;2rhi\033[4;0Hyou").assertLinesAre("hi ", " ", " ", "you");
+ //enterString("see").assertLinesAre("hi ", " ", " ", "see");
+ }
+
+ public void testNELRespectsLeftMargin() {
+ // vttest "Menu 11.3.2: VT420 Cursor-Movement Test", select "10. Test other movement (CR/HT/LF/FF) within margins".
+ // The NEL (ESC E) sequence moves cursor to first position on next line, where first position depends on origin mode and margin.
+ withTerminalSized(3, 3).enterString("\033[?69h\033[2sABC\033ED").assertLinesAre("ABC", "D ", " ");
+ withTerminalSized(3, 3).enterString("\033[?69h\033[2sABC\033[?6h\033ED").assertLinesAre("ABC", " D ", " ");
+ }
+
+ public void testRiRespectsLeftMargin() {
+ // Reverse Index (RI), ${ESC}M, should respect horizontal margins:
+ withTerminalSized(4, 3).enterString("ABCD\033[?69h\033[2;3s\033[?6h\033M").assertLinesAre("A D", " BC ", " ");
+ }
+
+ public void testSdRespectsLeftMargin() {
+ // Scroll Down (SD), ${CSI}${N}T, should respect horizontal margins:
+ withTerminalSized(4, 3).enterString("ABCD\033[?69h\033[2;3s\033[?6h\033[2T").assertLinesAre("A D", " ", " BC ");
+ }
+
+ public void testBackwardIndex() {
+ // vttest "Menu 11.3.2: VT420 Cursor-Movement Test", test 7.
+ // Without margins:
+ withTerminalSized(3, 3).enterString("ABCDEF\0336H").assertLinesAre("ABC", "DHF", " ");
+ enterString("\0336\0336I").assertLinesAre("ABC", "IHF", " ");
+ enterString("\0336\0336").assertLinesAre(" AB", " IH", " ");
+ // With left margin:
+ withTerminalSized(3, 3).enterString("\033[?69h\033[2sABCDEF\0336\0336").assertLinesAre("A B", " D", " F");
+ }
+
+ public void testForwardIndex() {
+ // vttest "Menu 11.3.2: VT420 Cursor-Movement Test", test 8.
+ // Without margins:
+ withTerminalSized(3, 3).enterString("ABCD\0339E").assertLinesAre("ABC", "D E", " ");
+ enterString("\0339").assertLinesAre("BC ", " E ", " ");
+ // With right margin:
+ withTerminalSized(3, 3).enterString("\033[?69h\033[0;2sABCD\0339").assertLinesAre("B ", "D ", " ");
+ }
+
+ public void testScrollDownWithScrollRegion() {
+ withTerminalSized(2, 5).enterString("1\r\n2\r\n3\r\n4\r\n5").assertLinesAre("1 ", "2 ", "3 ", "4 ", "5 ");
+ enterString("\033[3r").enterString("\033[2T").assertLinesAre("1 ", "2 ", " ", " ", "3 ");
+ }
+
+ public void testScrollDownBelowScrollRegion() {
+ withTerminalSized(2, 5).enterString("1\r\n2\r\n3\r\n4\r\n5").assertLinesAre("1 ", "2 ", "3 ", "4 ", "5 ");
+ enterString("\033[1;3r"); // DECSTBM margins.
+ enterString("\033[4;1H"); // Place cursor just below bottom margin.
+ enterString("QQ\r\nRR\r\n\r\n\r\nYY");
+ assertLinesAre("1 ", "2 ", "3 ", "QQ", "YY");
+ }
+
+ /** See https://github.com/termux/termux-app/issues/1340 */
+ public void testScrollRegionDoesNotLimitCursorMovement() {
+ withTerminalSized(6, 4)
+ .enterString("\033[4;7r\033[3;1Haaa\033[Axxx")
+ .assertLinesAre(
+ " ",
+ " xxx",
+ "aaa ",
+ " "
+ );
+
+ withTerminalSized(6, 4)
+ .enterString("\033[1;3r\033[3;1Haaa\033[Bxxx")
+ .assertLinesAre(
+ " ",
+ " ",
+ "aaa ",
+ " xxx"
+ );
+ }
+
+ /**
+ * See reported issue .
+ */
+ public void testClearingWhenScrollingWithMargins() {
+ int newForeground = 2;
+ int newBackground = 3;
+ int size = 3;
+ TerminalTestCase terminal = withTerminalSized(size, size)
+ // Enable horizontal margin and set left margin to 1:
+ .enterString("\033[?69h\033[2s")
+ // Set foreground and background color:
+ .enterString("\033[" + (30 + newForeground) + ";" + (40 + newBackground) + "m")
+ // Enter newlines to scroll down:
+ .enterString("\r\n\r\n\r\n\r\n\r\n");
+ for (int row = 0; row < size; row++) {
+ for (int col = 0; col < size; col++) {
+ // The first column (outside of the scrolling area, due to us setting a left scroll
+ // margin of 1) should be unmodified, the others should use the current style:
+ int expectedForeground = col == 0 ? TextStyle.COLOR_INDEX_FOREGROUND : newForeground;
+ int expectedBackground = col == 0 ? TextStyle.COLOR_INDEX_BACKGROUND : newBackground;
+ terminal.assertForegroundColorAt(row, col, expectedForeground);
+ terminal.assertBackgroundColorAt(row, col, expectedBackground);
+ }
+ }
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalRowTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalRowTest.java
new file mode 100644
index 0000000..043d608
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalRowTest.java
@@ -0,0 +1,432 @@
+package com.termux.terminal;
+
+import junit.framework.TestCase;
+
+import java.util.Arrays;
+import java.util.Random;
+
+public class TerminalRowTest extends TestCase {
+
+ /** The properties of these code points are validated in {@link #testStaticConstants()}. */
+ private static final int ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1 = 0x679C;
+ private static final int ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2 = 0x679D;
+ private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1 = 0x2070E;
+ private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2 = 0x20731;
+
+ /** Unicode Character 'MUSICAL SYMBOL G CLEF' (U+1D11E). Two java chars required for this. */
+ static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1 = 0x1D11E;
+ /** Unicode Character 'MUSICAL SYMBOL G CLEF OTTAVA ALTA' (U+1D11F). Two java chars required for this. */
+ private static final int TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2 = 0x1D11F;
+
+ private final int COLUMNS = 80;
+
+ /** A combining character. */
+ private static final int DIARESIS_CODEPOINT = 0x0308;
+
+ private TerminalRow row;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ row = new TerminalRow(COLUMNS, TextStyle.NORMAL);
+ }
+
+ private void assertLineStartsWith(int... codePoints) {
+ char[] chars = row.mText;
+ int charIndex = 0;
+ for (int i = 0; i < codePoints.length; i++) {
+ int lineCodePoint = chars[charIndex++];
+ if (Character.isHighSurrogate((char) lineCodePoint)) {
+ lineCodePoint = Character.toCodePoint((char) lineCodePoint, chars[charIndex++]);
+ }
+ assertEquals("Differing a code point index=" + i, codePoints[i], lineCodePoint);
+ }
+ }
+
+ private void assertColumnCharIndicesStartsWith(int... indices) {
+ for (int i = 0; i < indices.length; i++) {
+ int expected = indices[i];
+ int actual = row.findStartOfColumn(i);
+ assertEquals("At index=" + i, expected, actual);
+ }
+ }
+
+ public void testSimpleDiaresis() {
+ row.setChar(0, DIARESIS_CODEPOINT, 0);
+ assertEquals(81, row.getSpaceUsed());
+ row.setChar(0, DIARESIS_CODEPOINT, 0);
+ assertEquals(82, row.getSpaceUsed());
+ assertLineStartsWith(' ', DIARESIS_CODEPOINT, DIARESIS_CODEPOINT, ' ');
+ }
+
+ public void testStaticConstants() {
+ assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
+ assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
+ assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
+ assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
+
+ assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1));
+ assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2));
+ assertEquals(1, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1));
+ assertEquals(1, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2));
+
+ assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1));
+ assertEquals(2, Character.charCount(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2));
+ assertEquals(2, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1));
+ assertEquals(2, WcWidth.width(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2));
+
+ assertEquals(1, Character.charCount(DIARESIS_CODEPOINT));
+ assertEquals(0, WcWidth.width(DIARESIS_CODEPOINT));
+ }
+
+ public void testOneColumn() {
+ assertEquals(0, row.findStartOfColumn(0));
+ row.setChar(0, 'a', 0);
+ assertEquals(0, row.findStartOfColumn(0));
+ }
+
+ public void testAscii() {
+ assertEquals(0, row.findStartOfColumn(0));
+ row.setChar(0, 'a', 0);
+ assertLineStartsWith('a', ' ', ' ');
+ assertEquals(1, row.findStartOfColumn(1));
+ assertEquals(80, row.getSpaceUsed());
+ row.setChar(0, 'b', 0);
+ assertEquals(1, row.findStartOfColumn(1));
+ assertEquals(2, row.findStartOfColumn(2));
+ assertEquals(80, row.getSpaceUsed());
+ assertColumnCharIndicesStartsWith(0, 1, 2, 3);
+
+ char[] someChars = new char[]{'a', 'c', 'e', '4', '5', '6', '7', '8'};
+
+ char[] rawLine = new char[80];
+ Arrays.fill(rawLine, ' ');
+ Random random = new Random();
+ for (int i = 0; i < 1000; i++) {
+ int lineIndex = random.nextInt(rawLine.length);
+ int charIndex = random.nextInt(someChars.length);
+ rawLine[lineIndex] = someChars[charIndex];
+ row.setChar(lineIndex, someChars[charIndex], 0);
+ }
+ char[] lineChars = row.mText;
+ for (int i = 0; i < rawLine.length; i++) {
+ assertEquals(rawLine[i], lineChars[i]);
+ }
+ }
+
+ public void testUnicode() {
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(80, row.getSpaceUsed());
+
+ row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0);
+ assertEquals(81, row.getSpaceUsed());
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(2, row.findStartOfColumn(1));
+ assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, ' ', ' ');
+ assertColumnCharIndicesStartsWith(0, 2, 3, 4);
+
+ row.setChar(0, 'a', 0);
+ assertEquals(80, row.getSpaceUsed());
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(1, row.findStartOfColumn(1));
+ assertLineStartsWith('a', ' ', ' ');
+ assertColumnCharIndicesStartsWith(0, 1, 2, 3);
+
+ row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0);
+ row.setChar(1, 'a', 0);
+ assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 'a', ' ');
+
+ row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, 0);
+ row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2, 0);
+ assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_1, TWO_JAVA_CHARS_DISPLAY_WIDTH_ONE_2, ' ');
+ assertColumnCharIndicesStartsWith(0, 2, 4, 5);
+ assertEquals(82, row.getSpaceUsed());
+ }
+
+ public void testDoubleWidth() {
+ row.setChar(0, 'a', 0);
+ row.setChar(1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
+ assertLineStartsWith('a', ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, ' ');
+ assertColumnCharIndicesStartsWith(0, 1, 1, 2);
+ row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
+ assertLineStartsWith(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, ' ', ' ');
+ assertColumnCharIndicesStartsWith(0, 0, 1, 2);
+
+ row.setChar(0, ' ', 0);
+ assertLineStartsWith(' ', ' ', ' ', ' ');
+ assertColumnCharIndicesStartsWith(0, 1, 2, 3, 4);
+ row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
+ row.setChar(2, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
+ assertLineStartsWith(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2);
+ assertColumnCharIndicesStartsWith(0, 0, 1, 1, 2);
+ row.setChar(0, 'a', 0);
+ assertLineStartsWith('a', ' ', ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, ' ');
+ }
+
+ /** Just as {@link #testDoubleWidth()} but requires a surrogate pair. */
+ public void testDoubleWidthSurrogage() {
+ row.setChar(0, 'a', 0);
+ assertColumnCharIndicesStartsWith(0, 1, 2, 3, 4);
+
+ row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, 0);
+ assertColumnCharIndicesStartsWith(0, 1, 1, 3, 4);
+ assertLineStartsWith('a', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' ');
+ row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, 0);
+ assertColumnCharIndicesStartsWith(0, 0, 2, 3, 4);
+ assertLineStartsWith(TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, ' ', ' ', ' ');
+
+ row.setChar(0, ' ', 0);
+ assertLineStartsWith(' ', ' ', ' ', ' ');
+ row.setChar(0, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_1, 0);
+ row.setChar(1, TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, 0);
+ assertLineStartsWith(' ', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' ');
+ row.setChar(0, 'a', 0);
+ assertLineStartsWith('a', TWO_JAVA_CHARS_DISPLAY_WIDTH_TWO_2, ' ');
+ }
+
+ public void testReplacementChar() {
+ row.setChar(0, TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 0);
+ row.setChar(1, 'Y', 0);
+ assertLineStartsWith(TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 'Y', ' ', ' ');
+ }
+
+ public void testSurrogateCharsWithNormalDisplayWidth() {
+ // These requires a UTF-16 surrogate pair, and has a display width of one.
+ int first = 0x1D306;
+ int second = 0x1D307;
+ // Assert the above statement:
+ assertEquals(2, Character.toChars(first).length);
+ assertEquals(2, Character.toChars(second).length);
+
+ row.setChar(0, second, 0);
+ assertEquals(second, Character.toCodePoint(row.mText[0], row.mText[1]));
+ assertEquals(' ', row.mText[2]);
+ assertEquals(2, row.findStartOfColumn(1));
+
+ row.setChar(0, first, 0);
+ assertEquals(first, Character.toCodePoint(row.mText[0], row.mText[1]));
+ assertEquals(' ', row.mText[2]);
+ assertEquals(2, row.findStartOfColumn(1));
+
+ row.setChar(1, second, 0);
+ row.setChar(2, 'a', 0);
+ assertEquals(first, Character.toCodePoint(row.mText[0], row.mText[1]));
+ assertEquals(second, Character.toCodePoint(row.mText[2], row.mText[3]));
+ assertEquals('a', row.mText[4]);
+ assertEquals(' ', row.mText[5]);
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(2, row.findStartOfColumn(1));
+ assertEquals(4, row.findStartOfColumn(2));
+ assertEquals(5, row.findStartOfColumn(3));
+ assertEquals(6, row.findStartOfColumn(4));
+
+ row.setChar(0, ' ', 0);
+ assertEquals(' ', row.mText[0]);
+ assertEquals(second, Character.toCodePoint(row.mText[1], row.mText[2]));
+ assertEquals('a', row.mText[3]);
+ assertEquals(' ', row.mText[4]);
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(1, row.findStartOfColumn(1));
+ assertEquals(3, row.findStartOfColumn(2));
+ assertEquals(4, row.findStartOfColumn(3));
+ assertEquals(5, row.findStartOfColumn(4));
+
+ for (int i = 0; i < 80; i++) {
+ row.setChar(i, i % 2 == 0 ? first : second, 0);
+ }
+ for (int i = 0; i < 80; i++) {
+ int idx = row.findStartOfColumn(i);
+ assertEquals(i % 2 == 0 ? first : second, Character.toCodePoint(row.mText[idx], row.mText[idx + 1]));
+ }
+ for (int i = 0; i < 80; i++) {
+ row.setChar(i, i % 2 == 0 ? 'a' : 'b', 0);
+ }
+ for (int i = 0; i < 80; i++) {
+ int idx = row.findStartOfColumn(i);
+ assertEquals(i, idx);
+ assertEquals(i % 2 == 0 ? 'a' : 'b', row.mText[i]);
+ }
+ }
+
+ public void testOverwritingDoubleDisplayWidthWithNormalDisplayWidth() {
+ // Initial "OO "
+ row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
+ assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
+ assertEquals(' ', row.mText[1]);
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(0, row.findStartOfColumn(1));
+ assertEquals(1, row.findStartOfColumn(2));
+
+ // Setting first column to a clears second: "a "
+ row.setChar(0, 'a', 0);
+ assertEquals('a', row.mText[0]);
+ assertEquals(' ', row.mText[1]);
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(1, row.findStartOfColumn(1));
+ assertEquals(2, row.findStartOfColumn(2));
+
+ // Back to initial "OO "
+ row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
+ assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
+ assertEquals(' ', row.mText[1]);
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(0, row.findStartOfColumn(1));
+ assertEquals(1, row.findStartOfColumn(2));
+
+ // Setting first column to a clears first: " a "
+ row.setChar(1, 'a', 0);
+ assertEquals(' ', row.mText[0]);
+ assertEquals('a', row.mText[1]);
+ assertEquals(' ', row.mText[2]);
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(1, row.findStartOfColumn(1));
+ assertEquals(2, row.findStartOfColumn(2));
+ }
+
+ public void testOverwritingDoubleDisplayWidthWithSelf() {
+ row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
+ row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
+ assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
+ assertEquals(' ', row.mText[1]);
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(0, row.findStartOfColumn(1));
+ assertEquals(1, row.findStartOfColumn(2));
+ }
+
+ public void testNormalCharsWithDoubleDisplayWidth() {
+ // These fit in one java char, and has a display width of two.
+ assertTrue(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1 != ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2);
+ assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
+ assertEquals(1, Character.charCount(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
+ assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1));
+ assertEquals(2, WcWidth.width(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2));
+
+ row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
+ assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
+ assertEquals(0, row.findStartOfColumn(1));
+ assertEquals(' ', row.mText[1]);
+
+ row.setChar(0, 'a', 0);
+ assertEquals('a', row.mText[0]);
+ assertEquals(' ', row.mText[1]);
+ assertEquals(1, row.findStartOfColumn(1));
+
+ row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
+ assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
+ // The first character fills both first columns.
+ assertEquals(0, row.findStartOfColumn(1));
+ row.setChar(2, 'a', 0);
+ assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
+ assertEquals('a', row.mText[1]);
+ assertEquals(1, row.findStartOfColumn(2));
+
+ row.setChar(0, 'c', 0);
+ assertEquals('c', row.mText[0]);
+ assertEquals(' ', row.mText[1]);
+ assertEquals('a', row.mText[2]);
+ assertEquals(' ', row.mText[3]);
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(1, row.findStartOfColumn(1));
+ assertEquals(2, row.findStartOfColumn(2));
+ }
+
+ public void testNormalCharsWithDoubleDisplayWidthOverlapping() {
+ // These fit in one java char, and has a display width of two.
+ row.setChar(0, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, 0);
+ row.setChar(2, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
+ row.setChar(4, 'a', 0);
+ // O = ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO
+ // A = ANOTHER_JAVA_CHAR_DISPLAY_WIDTH_TWO
+ // "OOAAa "
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(0, row.findStartOfColumn(1));
+ assertEquals(1, row.findStartOfColumn(2));
+ assertEquals(1, row.findStartOfColumn(3));
+ assertEquals(2, row.findStartOfColumn(4));
+ assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1, row.mText[0]);
+ assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, row.mText[1]);
+ assertEquals('a', row.mText[2]);
+ assertEquals(' ', row.mText[3]);
+
+ row.setChar(1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, 0);
+ // " AA a "
+ assertEquals(' ', row.mText[0]);
+ assertEquals(ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_2, row.mText[1]);
+ assertEquals(' ', row.mText[2]);
+ assertEquals('a', row.mText[3]);
+ assertEquals(' ', row.mText[4]);
+ assertEquals(0, row.findStartOfColumn(0));
+ assertEquals(1, row.findStartOfColumn(1));
+ assertEquals(1, row.findStartOfColumn(2));
+ assertEquals(2, row.findStartOfColumn(3));
+ assertEquals(3, row.findStartOfColumn(4));
+ }
+
+ // https://github.com/jackpal/Android-Terminal-Emulator/issues/145
+ public void testCrashATE145() {
+ // 0xC2541 is unassigned, use display width 1 for UNICODE_REPLACEMENT_CHAR.
+ // assertEquals(1, WcWidth.width(0xC2541));
+ assertEquals(2, Character.charCount(0xC2541));
+
+ assertEquals(2, WcWidth.width(0x73EE));
+ assertEquals(1, Character.charCount(0x73EE));
+
+ assertEquals(0, WcWidth.width(0x009F));
+ assertEquals(1, Character.charCount(0x009F));
+
+ int[] points = new int[]{0xC2541, 'a', '8', 0x73EE, 0x009F, 0x881F, 0x8324, 0xD4C9, 0xFFFD, 'B', 0x009B, 0x61C9, 'Z'};
+ // int[] expected = new int[] { TerminalEmulator.UNICODE_REPLACEMENT_CHAR, 'a', '8', 0x73EE, 0x009F, 0x881F, 0x8324, 0xD4C9, 0xFFFD,
+ // 'B', 0x009B, 0x61C9, 'Z' };
+ int currentColumn = 0;
+ for (int point : points) {
+ row.setChar(currentColumn, point, 0);
+ currentColumn += WcWidth.width(point);
+ }
+ // assertLineStartsWith(points);
+ // assertEquals(Character.highSurrogate(0xC2541), line.mText[0]);
+ // assertEquals(Character.lowSurrogate(0xC2541), line.mText[1]);
+ // assertEquals('a', line.mText[2]);
+ // assertEquals('8', line.mText[3]);
+ // assertEquals(Character.highSurrogate(0x73EE), line.mText[4]);
+ // assertEquals(Character.lowSurrogate(0x73EE), line.mText[5]);
+ //
+ // char[] chars = line.mText;
+ // int charIndex = 0;
+ // for (int i = 0; i < points.length; i++) {
+ // char c = chars[charIndex];
+ // charIndex++;
+ // int thisPoint = (int) c;
+ // if (Character.isHighSurrogate(c)) {
+ // thisPoint = Character.toCodePoint(c, chars[charIndex]);
+ // charIndex++;
+ // }
+ // assertEquals("At index=" + i + ", charIndex=" + charIndex + ", char=" + (char) thisPoint, points[i], thisPoint);
+ // }
+ }
+
+ public void testNormalization() {
+ // int lowerCaseN = 0x006E;
+ // int combiningTilde = 0x0303;
+ // int combined = 0x00F1;
+ row.setChar(0, 0x006E, 0);
+ assertEquals(80, row.getSpaceUsed());
+ row.setChar(0, 0x0303, 0);
+ assertEquals(81, row.getSpaceUsed());
+ // assertEquals("\u00F1 ", new String(term.getScreen().getLine(0)));
+ assertLineStartsWith(0x006E, 0x0303, ' ');
+ }
+
+ public void testInsertWideAtLastColumn() {
+ row.setChar(COLUMNS - 2, 'Z', 0);
+ row.setChar(COLUMNS - 1, 'a', 0);
+ assertEquals('Z', row.mText[row.findStartOfColumn(COLUMNS - 2)]);
+ assertEquals('a', row.mText[row.findStartOfColumn(COLUMNS - 1)]);
+ row.setChar(COLUMNS - 1, 'ö', 0);
+ assertEquals('Z', row.mText[row.findStartOfColumn(COLUMNS - 2)]);
+ assertEquals('ö', row.mText[row.findStartOfColumn(COLUMNS - 1)]);
+ // line.setChar(COLUMNS - 1, ONE_JAVA_CHAR_DISPLAY_WIDTH_TWO_1);
+ // assertEquals('Z', line.mText[line.findStartOfColumn(COLUMNS - 2)]);
+ // assertEquals(' ', line.mText[line.findStartOfColumn(COLUMNS - 1)]);
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalTest.java
new file mode 100644
index 0000000..3e1f824
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalTest.java
@@ -0,0 +1,350 @@
+package com.termux.terminal;
+
+import java.io.UnsupportedEncodingException;
+
+public class TerminalTest extends TerminalTestCase {
+
+ public void testCursorPositioning() throws Exception {
+ withTerminalSized(10, 10).placeCursorAndAssert(1, 2).placeCursorAndAssert(3, 5).placeCursorAndAssert(2, 2).enterString("A")
+ .assertCursorAt(2, 3);
+ }
+
+ public void testScreen() throws UnsupportedEncodingException {
+ withTerminalSized(3, 3);
+ assertLinesAre(" ", " ", " ");
+
+ assertEquals("", mTerminal.getScreen().getTranscriptText());
+ enterString("hi").assertLinesAre("hi ", " ", " ");
+ assertEquals("hi", mTerminal.getScreen().getTranscriptText());
+ enterString("\r\nu");
+ assertEquals("hi\nu", mTerminal.getScreen().getTranscriptText());
+ mTerminal.reset();
+ assertEquals("hi\nu", mTerminal.getScreen().getTranscriptText());
+
+ withTerminalSized(3, 3).enterString("hello");
+ assertEquals("hello", mTerminal.getScreen().getTranscriptText());
+ enterString("\r\nworld");
+ assertEquals("hello\nworld", mTerminal.getScreen().getTranscriptText());
+ }
+
+ public void testScrollDownInAltBuffer() {
+ withTerminalSized(3, 3).enterString("\033[?1049h");
+ enterString("\033[38;5;111m1\r\n");
+ enterString("\033[38;5;112m2\r\n");
+ enterString("\033[38;5;113m3\r\n");
+ enterString("\033[38;5;114m4\r\n");
+ enterString("\033[38;5;115m5");
+ assertLinesAre("3 ", "4 ", "5 ");
+ assertForegroundColorAt(0, 0, 113);
+ assertForegroundColorAt(1, 0, 114);
+ assertForegroundColorAt(2, 0, 115);
+ }
+
+ public void testMouseClick() throws Exception {
+ withTerminalSized(10, 10);
+ assertFalse(mTerminal.isMouseTrackingActive());
+ enterString("\033[?1000h");
+ assertTrue(mTerminal.isMouseTrackingActive());
+ enterString("\033[?1000l");
+ assertFalse(mTerminal.isMouseTrackingActive());
+ enterString("\033[?1000h");
+ assertTrue(mTerminal.isMouseTrackingActive());
+
+ enterString("\033[?1006h");
+ mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 3, 4, true);
+ assertEquals("\033[<0;3;4M", mOutput.getOutputAndClear());
+ mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 3, 4, false);
+ assertEquals("\033[<0;3;4m", mOutput.getOutputAndClear());
+
+ // When the client says that a click is outside (which could happen when pixels are outside
+ // the terminal area, see https://github.com/termux/termux-app/issues/501) the terminal
+ // sends a click at the edge.
+ mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 0, 0, true);
+ assertEquals("\033[<0;1;1M", mOutput.getOutputAndClear());
+ mTerminal.sendMouseEvent(TerminalEmulator.MOUSE_LEFT_BUTTON, 11, 11, false);
+ assertEquals("\033[<0;10;10m", mOutput.getOutputAndClear());
+ }
+
+ public void testNormalization() throws UnsupportedEncodingException {
+ // int lowerCaseN = 0x006E;
+ // int combiningTilde = 0x0303;
+ // int combined = 0x00F1;
+ withTerminalSized(3, 3).assertLinesAre(" ", " ", " ");
+ enterString("\u006E\u0303");
+ assertEquals(1, WcWidth.width("\u006E\u0303".toCharArray(), 0));
+ // assertEquals("\u00F1 ", new String(mTerminal.getScreen().getLine(0)));
+ assertLinesAre("\u006E\u0303 ", " ", " ");
+ }
+
+ /** On "\e[18t" xterm replies with "\e[8;${HEIGHT};${WIDTH}t" */
+ public void testReportTerminalSize() throws Exception {
+ withTerminalSized(5, 5);
+ assertEnteringStringGivesResponse("\033[18t", "\033[8;5;5t");
+ for (int width = 3; width < 12; width++) {
+ for (int height = 3; height < 12; height++) {
+ resize(width, height);
+ assertEnteringStringGivesResponse("\033[18t", "\033[8;" + height + ";" + width + "t");
+ }
+ }
+ }
+
+ /** Device Status Report (DSR) and Report Cursor Position (CPR). */
+ public void testDeviceStatusReport() throws Exception {
+ withTerminalSized(5, 5);
+ assertEnteringStringGivesResponse("\033[5n", "\033[0n");
+
+ assertEnteringStringGivesResponse("\033[6n", "\033[1;1R");
+ enterString("AB");
+ assertEnteringStringGivesResponse("\033[6n", "\033[1;3R");
+ enterString("\r\n");
+ assertEnteringStringGivesResponse("\033[6n", "\033[2;1R");
+ }
+
+ /** Test the cursor shape changes using DECSCUSR. */
+ public void testSetCursorStyle() throws Exception {
+ withTerminalSized(5, 5);
+ assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
+ enterString("\033[3 q");
+ assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle());
+ enterString("\033[5 q");
+ assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR, mTerminal.getCursorStyle());
+ enterString("\033[0 q");
+ assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
+ enterString("\033[6 q");
+ assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR, mTerminal.getCursorStyle());
+ enterString("\033[4 q");
+ assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle());
+ enterString("\033[1 q");
+ assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
+ enterString("\033[4 q");
+ assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE, mTerminal.getCursorStyle());
+ enterString("\033[2 q");
+ assertEquals(TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK, mTerminal.getCursorStyle());
+ }
+
+ public void testPaste() {
+ withTerminalSized(5, 5);
+ mTerminal.paste("hi");
+ assertEquals("hi", mOutput.getOutputAndClear());
+
+ enterString("\033[?2004h");
+ mTerminal.paste("hi");
+ assertEquals("\033[200~" + "hi" + "\033[201~", mOutput.getOutputAndClear());
+
+ enterString("\033[?2004l");
+ mTerminal.paste("hi");
+ assertEquals("hi", mOutput.getOutputAndClear());
+ }
+
+ public void testSelectGraphics() {
+ selectGraphicsTestRun(';');
+ selectGraphicsTestRun(':');
+ }
+
+ public void selectGraphicsTestRun(char separator) {
+ withTerminalSized(5, 5);
+ enterString("\033[31m");
+ assertEquals(mTerminal.mForeColor, 1);
+ enterString("\033[32m");
+ assertEquals(mTerminal.mForeColor, 2);
+ enterString("\033[43m");
+ assertEquals(2, mTerminal.mForeColor);
+ assertEquals(3, mTerminal.mBackColor);
+
+ // SGR 0 should reset both foreground and background color.
+ enterString("\033[0m");
+ assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
+ assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
+
+ // Test CSI resetting to default if sequence starts with ; or has sequential ;;
+ // Check TerminalEmulator.parseArg()
+ enterString("\033[31m\033[m");
+ assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
+ enterString("\033[31m\033[;m".replace(';', separator));
+ assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
+ enterString("\033[31m\033[0m");
+ assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
+ enterString("\033[31m\033[0;m".replace(';', separator));
+ assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
+ enterString("\033[31;;m");
+ assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
+ enterString("\033[31::m");
+ assertEquals(1, mTerminal.mForeColor);
+ enterString("\033[31;m");
+ assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
+ enterString("\033[31:m");
+ assertEquals(1, mTerminal.mForeColor);
+ enterString("\033[31;;41m");
+ assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
+ assertEquals(1, mTerminal.mBackColor);
+ enterString("\033[0m");
+ assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
+
+ // 256 colors:
+ enterString("\033[38;5;119m".replace(';', separator));
+ assertEquals(119, mTerminal.mForeColor);
+ assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
+ enterString("\033[48;5;129m".replace(';', separator));
+ assertEquals(119, mTerminal.mForeColor);
+ assertEquals(129, mTerminal.mBackColor);
+
+ // Invalid parameter:
+ enterString("\033[48;8;129m".replace(';', separator));
+ assertEquals(119, mTerminal.mForeColor);
+ assertEquals(129, mTerminal.mBackColor);
+
+ // Multiple parameters at once:
+ enterString("\033[38;5;178".replace(';', separator) + ";" + "48;5;179m".replace(';', separator));
+ assertEquals(178, mTerminal.mForeColor);
+ assertEquals(179, mTerminal.mBackColor);
+
+ // Omitted parameter means zero:
+ enterString("\033[38;5;m".replace(';', separator));
+ assertEquals(0, mTerminal.mForeColor);
+ assertEquals(179, mTerminal.mBackColor);
+ enterString("\033[48;5;m".replace(';', separator));
+ assertEquals(0, mTerminal.mForeColor);
+ assertEquals(0, mTerminal.mBackColor);
+
+ // 24 bit colors:
+ enterString(("\033[0m")); // Reset fg and bg colors.
+ enterString("\033[38;2;255;127;2m".replace(';', separator));
+ int expectedForeground = 0xff000000 | (255 << 16) | (127 << 8) | 2;
+ assertEquals(expectedForeground, mTerminal.mForeColor);
+ assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
+ enterString("\033[48;2;1;2;254m".replace(';', separator));
+ int expectedBackground = 0xff000000 | (1 << 16) | (2 << 8) | 254;
+ assertEquals(expectedForeground, mTerminal.mForeColor);
+ assertEquals(expectedBackground, mTerminal.mBackColor);
+
+ // 24 bit colors, set fg and bg at once:
+ enterString(("\033[0m")); // Reset fg and bg colors.
+ assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, mTerminal.mForeColor);
+ assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
+ enterString("\033[38;2;255;127;2".replace(';', separator) + ";" + "48;2;1;2;254m".replace(';', separator));
+ assertEquals(expectedForeground, mTerminal.mForeColor);
+ assertEquals(expectedBackground, mTerminal.mBackColor);
+
+ // 24 bit colors, invalid input:
+ enterString("\033[38;2;300;127;2;48;2;1;300;254m".replace(';', separator));
+ assertEquals(expectedForeground, mTerminal.mForeColor);
+ assertEquals(expectedBackground, mTerminal.mBackColor);
+
+ // 24 bit colors, omitted parameter means zero:
+ enterString("\033[38;2;255;127;m".replace(';', separator));
+ expectedForeground = 0xff000000 | (255 << 16) | (127 << 8);
+ assertEquals(expectedForeground, mTerminal.mForeColor);
+ assertEquals(expectedBackground, mTerminal.mBackColor);
+ enterString("\033[38;2;123;;77m".replace(';', separator));
+ expectedForeground = 0xff000000 | (123 << 16) | 77;
+ assertEquals(expectedForeground, mTerminal.mForeColor);
+ assertEquals(expectedBackground, mTerminal.mBackColor);
+
+ // 24 bit colors, extra sub-parameters are skipped:
+ expectedForeground = 0xff000000 | (255 << 16) | (127 << 8) | 2;
+ enterString("\033[0;38:2:255:127:2:48:2:1:2:254m");
+ assertEquals(expectedForeground, mTerminal.mForeColor);
+ assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, mTerminal.mBackColor);
+ }
+
+ public void testBackgroundColorErase() {
+ final int rows = 3;
+ final int cols = 3;
+ withTerminalSized(cols, rows);
+ for (int r = 0; r < rows; r++) {
+ for (int c = 0; c < cols; c++) {
+ long style = getStyleAt(r, c);
+ assertEquals(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.decodeForeColor(style));
+ assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, TextStyle.decodeBackColor(style));
+ }
+ }
+ // Foreground color to 119:
+ enterString("\033[38;5;119m");
+ // Background color to 129:
+ enterString("\033[48;5;129m");
+ // Clear with ED, Erase in Display:
+ enterString("\033[2J");
+ for (int r = 0; r < rows; r++) {
+ for (int c = 0; c < cols; c++) {
+ long style = getStyleAt(r, c);
+ assertEquals(119, TextStyle.decodeForeColor(style));
+ assertEquals(129, TextStyle.decodeBackColor(style));
+ }
+ }
+ // Background color to 139:
+ enterString("\033[48;5;139m");
+ // Insert two blank lines.
+ enterString("\033[2L");
+ for (int r = 0; r < rows; r++) {
+ for (int c = 0; c < cols; c++) {
+ long style = getStyleAt(r, c);
+ assertEquals((r == 0 || r == 1) ? 139 : 129, TextStyle.decodeBackColor(style));
+ }
+ }
+
+ withTerminalSized(cols, rows);
+ // Background color to 129:
+ enterString("\033[48;5;129m");
+ // Erase two characters, filling them with background color:
+ enterString("\033[2X");
+ assertEquals(129, TextStyle.decodeBackColor(getStyleAt(0, 0)));
+ assertEquals(129, TextStyle.decodeBackColor(getStyleAt(0, 1)));
+ assertEquals(TextStyle.COLOR_INDEX_BACKGROUND, TextStyle.decodeBackColor(getStyleAt(0, 2)));
+ }
+
+ public void testParseColor() {
+ assertEquals(0xFF0000FA, TerminalColors.parse("#0000FA"));
+ assertEquals(0xFF000000, TerminalColors.parse("#000000"));
+ assertEquals(0xFF000000, TerminalColors.parse("#000"));
+ assertEquals(0xFF000000, TerminalColors.parse("#000000000"));
+ assertEquals(0xFF53186f, TerminalColors.parse("#53186f"));
+
+ assertEquals(0xFFFF00FF, TerminalColors.parse("rgb:F/0/F"));
+ assertEquals(0xFF0000FA, TerminalColors.parse("rgb:00/00/FA"));
+ assertEquals(0xFF53186f, TerminalColors.parse("rgb:53/18/6f"));
+
+ assertEquals(0, TerminalColors.parse("invalid_0000FA"));
+ assertEquals(0, TerminalColors.parse("#3456"));
+ }
+
+ /** The ncurses library still uses this. */
+ public void testLineDrawing() {
+ // 016 - shift out / G1. 017 - shift in / G0. "ESC ) 0" - use line drawing for G1
+ withTerminalSized(4, 2).enterString("q\033)0q\016q\017q").assertLinesAre("qq─q", " ");
+ // "\0337", saving cursor should save G0, G1 and invoked charset and "ESC 8" should restore.
+ withTerminalSized(4, 2).enterString("\033)0\016qqq\0337\017\0338q").assertLinesAre("────", " ");
+ }
+
+ public void testSoftTerminalReset() {
+ // See http://vt100.net/docs/vt510-rm/DECSTR and https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=650304
+ // "\033[?7l" is DECRST to disable wrap-around, and DECSTR ("\033[!p") should reset it.
+ withTerminalSized(3, 3).enterString("\033[?7lABCD").assertLinesAre("ABD", " ", " ");
+ enterString("\033[!pEF").assertLinesAre("ABE", "F ", " ");
+ }
+
+ public void testBel() {
+ withTerminalSized(3, 3);
+ assertEquals(0, mOutput.bellsRung);
+ enterString("\07");
+ assertEquals(1, mOutput.bellsRung);
+ enterString("hello\07");
+ assertEquals(2, mOutput.bellsRung);
+ enterString("\07hello");
+ assertEquals(3, mOutput.bellsRung);
+ enterString("hello\07world");
+ assertEquals(4, mOutput.bellsRung);
+ }
+
+ public void testAutomargins() throws UnsupportedEncodingException {
+ withTerminalSized(3, 3).enterString("abc").assertLinesAre("abc", " ", " ").assertCursorAt(0, 2);
+ enterString("d").assertLinesAre("abc", "d ", " ").assertCursorAt(1, 1);
+
+ withTerminalSized(3, 3).enterString("abc\r ").assertLinesAre(" bc", " ", " ").assertCursorAt(0, 1);
+ }
+
+ public void testTab() {
+ withTerminalSized(11, 2).enterString("01234567890\r\tXX").assertLinesAre("01234567XX0", " ");
+ withTerminalSized(11, 2).enterString("01234567890\033[44m\r\tXX").assertLinesAre("01234567XX0", " ");
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java b/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java
new file mode 100644
index 0000000..4490207
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java
@@ -0,0 +1,319 @@
+package com.termux.terminal;
+
+import junit.framework.AssertionFailedError;
+import junit.framework.TestCase;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+public abstract class TerminalTestCase extends TestCase {
+
+ public static final int INITIAL_CELL_WIDTH_PIXELS = 13;
+ public static final int INITIAL_CELL_HEIGHT_PIXELS = 15;
+
+ public static class MockTerminalOutput extends TerminalOutput {
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ public final List titleChanges = new ArrayList<>();
+ public final List clipboardPuts = new ArrayList<>();
+ public int bellsRung = 0;
+ public int colorsChanged = 0;
+
+ @Override
+ public void write(byte[] data, int offset, int count) {
+ baos.write(data, offset, count);
+ }
+
+ public String getOutputAndClear() {
+ String result = new String(baos.toByteArray(), StandardCharsets.UTF_8);
+ baos.reset();
+ return result;
+ }
+
+ @Override
+ public void titleChanged(String oldTitle, String newTitle) {
+ titleChanges.add(new ChangedTitle(oldTitle, newTitle));
+ }
+
+ @Override
+ public void onCopyTextToClipboard(String text) {
+ clipboardPuts.add(text);
+ }
+
+ @Override
+ public void onPasteTextFromClipboard() {
+ }
+
+ @Override
+ public void onBell() {
+ bellsRung++;
+ }
+
+ @Override
+ public void onColorsChanged() {
+ colorsChanged++;
+ }
+ }
+
+ public TerminalEmulator mTerminal;
+ public MockTerminalOutput mOutput;
+
+ public static final class ChangedTitle {
+ final String oldTitle;
+ final String newTitle;
+
+ public ChangedTitle(String oldTitle, String newTitle) {
+ this.oldTitle = oldTitle;
+ this.newTitle = newTitle;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof ChangedTitle)) return false;
+ ChangedTitle other = (ChangedTitle) o;
+ return Objects.equals(oldTitle, other.oldTitle) && Objects.equals(newTitle, other.newTitle);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(oldTitle, newTitle);
+ }
+
+ @Override
+ public String toString() {
+ return "ChangedTitle[oldTitle=" + oldTitle + ", newTitle=" + newTitle + "]";
+ }
+
+ }
+
+ public TerminalTestCase enterString(String s) {
+ byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
+ mTerminal.append(bytes, bytes.length);
+ assertInvariants();
+ return this;
+ }
+
+ public void assertEnteringStringGivesResponse(String input, String expectedResponse) {
+ enterString(input);
+ String response = mOutput.getOutputAndClear();
+ assertEquals(expectedResponse, response);
+ }
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ mOutput = new MockTerminalOutput();
+ }
+
+ protected TerminalTestCase withTerminalSized(int columns, int rows) {
+ // The tests aren't currently using the client, so a null client will suffice, a dummy client should be implemented if needed
+ mTerminal = new TerminalEmulator(mOutput, columns, rows, INITIAL_CELL_WIDTH_PIXELS, INITIAL_CELL_HEIGHT_PIXELS, rows * 2, null);
+ return this;
+ }
+
+ public void assertHistoryStartsWith(String... rows) {
+ assertTrue("About to check " + rows.length + " lines, but only " + mTerminal.getScreen().getActiveTranscriptRows() + " in history",
+ mTerminal.getScreen().getActiveTranscriptRows() >= rows.length);
+ for (int i = 0; i < rows.length; i++) {
+ assertLineIs(-i - 1, rows[i]);
+ }
+ }
+
+ private static final class LineWrapper {
+ final TerminalRow mLine;
+
+ public LineWrapper(TerminalRow line) {
+ mLine = line;
+ }
+
+ @Override
+ public int hashCode() {
+ return System.identityHashCode(mLine);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return o instanceof LineWrapper && ((LineWrapper) o).mLine == mLine;
+ }
+ }
+
+ protected TerminalTestCase assertInvariants() {
+ TerminalBuffer screen = mTerminal.getScreen();
+ TerminalRow[] lines = screen.mLines;
+
+ Set linesSet = new HashSet<>();
+ for (int i = 0; i < lines.length; i++) {
+ if (lines[i] == null) continue;
+ assertTrue("Line exists at multiple places: " + i, linesSet.add(new LineWrapper(lines[i])));
+ char[] text = lines[i].mText;
+ int usedChars = lines[i].getSpaceUsed();
+ int currentColumn = 0;
+ for (int j = 0; j < usedChars; j++) {
+ char c = text[j];
+ int codePoint;
+ if (Character.isHighSurrogate(c)) {
+ char lowSurrogate = text[++j];
+ assertTrue("High surrogate without following low surrogate", Character.isLowSurrogate(lowSurrogate));
+ codePoint = Character.toCodePoint(c, lowSurrogate);
+ } else {
+ assertFalse("Low surrogate without preceding high surrogate", Character.isLowSurrogate(c));
+ codePoint = c;
+ }
+ assertFalse("Screen should never contain unassigned characters", Character.getType(codePoint) == Character.UNASSIGNED);
+ int width = WcWidth.width(codePoint);
+ assertFalse("The first column should not start with combining character", currentColumn == 0 && width < 0);
+ if (width > 0) currentColumn += width;
+ }
+ assertEquals("Line whose width does not match screens. line=" + new String(lines[i].mText, 0, lines[i].getSpaceUsed()),
+ screen.mColumns, currentColumn);
+ }
+
+ assertEquals("The alt buffer should have have no history", mTerminal.mAltBuffer.mTotalRows, mTerminal.mAltBuffer.mScreenRows);
+ if (mTerminal.isAlternateBufferActive()) {
+ assertEquals("The alt buffer should be the same size as the screen", mTerminal.mRows, mTerminal.mAltBuffer.mTotalRows);
+ }
+
+ return this;
+ }
+
+ protected void assertLineIs(int line, String expected) {
+ TerminalRow l = mTerminal.getScreen().allocateFullLineIfNecessary(mTerminal.getScreen().externalToInternalRow(line));
+ char[] chars = l.mText;
+ int textLen = l.getSpaceUsed();
+ if (textLen != expected.length()) fail("Expected '" + expected + "' (len=" + expected.length() + "), was='"
+ + new String(chars, 0, textLen) + "' (len=" + textLen + ")");
+ for (int i = 0; i < textLen; i++) {
+ if (expected.charAt(i) != chars[i])
+ fail("Expected '" + expected + "', was='" + new String(chars, 0, textLen) + "' - first different at index=" + i);
+ }
+ }
+
+ public TerminalTestCase assertLinesAre(String... lines) {
+ assertEquals(lines.length, mTerminal.getScreen().mScreenRows);
+ for (int i = 0; i < lines.length; i++)
+ try {
+ assertLineIs(i, lines[i]);
+ } catch (AssertionFailedError e) {
+ throw new AssertionFailedError("Line: " + i + " - " + e.getMessage());
+ }
+ return this;
+ }
+
+ public TerminalTestCase resize(int cols, int rows) {
+ mTerminal.resize(cols, rows, INITIAL_CELL_WIDTH_PIXELS, INITIAL_CELL_HEIGHT_PIXELS);
+ assertInvariants();
+ return this;
+ }
+
+ public TerminalTestCase assertLineWraps(boolean... lines) {
+ for (int i = 0; i < lines.length; i++)
+ assertEquals("line=" + i, lines[i], mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(i)].mLineWrap);
+ return this;
+ }
+
+ protected TerminalTestCase assertLineStartsWith(int line, int... codePoints) {
+ char[] chars = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(line)].mText;
+ int charIndex = 0;
+ for (int i = 0; i < codePoints.length; i++) {
+ int lineCodePoint = chars[charIndex++];
+ if (Character.isHighSurrogate((char) lineCodePoint)) {
+ lineCodePoint = Character.toCodePoint((char) lineCodePoint, chars[charIndex++]);
+ }
+ assertEquals("Differing a code point index=" + i, codePoints[i], lineCodePoint);
+ }
+ return this;
+ }
+
+ protected TerminalTestCase placeCursorAndAssert(int row, int col) {
+ // +1 due to escape sequence being one based.
+ enterString("\033[" + (row + 1) + ";" + (col + 1) + "H");
+ assertCursorAt(row, col);
+ return this;
+ }
+
+ public TerminalTestCase assertCursorAt(int row, int col) {
+ int actualRow = mTerminal.getCursorRow();
+ int actualCol = mTerminal.getCursorCol();
+ if (!(row == actualRow && col == actualCol))
+ fail("Expected cursor at (row,col)=(" + row + ", " + col + ") but was (" + actualRow + ", " + actualCol + ")");
+ return this;
+ }
+
+ /** For testing only. Encoded style according to {@link TextStyle}. */
+ public long getStyleAt(int externalRow, int column) {
+ return mTerminal.getScreen().getStyleAt(externalRow, column);
+ }
+
+ public static class EffectLine {
+ final int[] styles;
+
+ public EffectLine(int[] styles) {
+ this.styles = styles;
+ }
+ }
+
+ protected EffectLine effectLine(int... bits) {
+ return new EffectLine(bits);
+ }
+
+ public TerminalTestCase assertEffectAttributesSet(EffectLine... lines) {
+ assertEquals(lines.length, mTerminal.getScreen().mScreenRows);
+ for (int i = 0; i < lines.length; i++) {
+ int[] line = lines[i].styles;
+ for (int j = 0; j < line.length; j++) {
+ int effectsAtCell = TextStyle.decodeEffect(getStyleAt(i, j));
+ int attributes = line[j];
+ if ((effectsAtCell & attributes) != attributes) fail("Line=" + i + ", column=" + j + ", expected "
+ + describeStyle(attributes) + " set, was " + describeStyle(effectsAtCell));
+ }
+ }
+ return this;
+ }
+
+ public TerminalTestCase assertForegroundIndices(EffectLine... lines) {
+ assertEquals(lines.length, mTerminal.getScreen().mScreenRows);
+ for (int i = 0; i < lines.length; i++) {
+ int[] line = lines[i].styles;
+ for (int j = 0; j < line.length; j++) {
+ int actualColor = TextStyle.decodeForeColor(getStyleAt(i, j));
+ int expectedColor = line[j];
+ if (actualColor != expectedColor) fail("Line=" + i + ", column=" + j + ", expected color "
+ + Integer.toHexString(expectedColor) + " set, was " + Integer.toHexString(actualColor));
+ }
+ }
+ return this;
+ }
+
+ private static String describeStyle(int styleBits) {
+ return "'" + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_BLINK) != 0 ? ":BLINK:" : "")
+ + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_BOLD) != 0 ? ":BOLD:" : "")
+ + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_INVERSE) != 0 ? ":INVERSE:" : "")
+ + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) != 0 ? ":INVISIBLE:" : "")
+ + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0 ? ":ITALIC:" : "")
+ + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) != 0 ? ":PROTECTED:" : "")
+ + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0 ? ":STRIKETHROUGH:" : "")
+ + ((styleBits & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0 ? ":UNDERLINE:" : "") + "'";
+ }
+
+ public void assertForegroundColorAt(int externalRow, int column, int color) {
+ long style = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(externalRow)].getStyle(column);
+ assertEquals(color, TextStyle.decodeForeColor(style));
+ }
+
+ public void assertBackgroundColorAt(int externalRow, int column, int color) {
+ long style = mTerminal.getScreen().mLines[mTerminal.getScreen().externalToInternalRow(externalRow)].getStyle(column);
+ assertEquals(color, TextStyle.decodeBackColor(style));
+ }
+
+ public TerminalTestCase assertColor(int colorIndex, int expected) {
+ int actual = mTerminal.mColors.mCurrentColors[colorIndex];
+ if (expected != actual) {
+ fail("Color index=" + colorIndex + ", expected=" + Integer.toHexString(expected) + ", was=" + Integer.toHexString(actual));
+ }
+ return this;
+ }
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/TextStyleTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/TextStyleTest.java
new file mode 100644
index 0000000..6ec7a34
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/TextStyleTest.java
@@ -0,0 +1,65 @@
+package com.termux.terminal;
+
+import junit.framework.TestCase;
+
+public class TextStyleTest extends TestCase {
+
+ private static final int[] ALL_EFFECTS = new int[]{0, TextStyle.CHARACTER_ATTRIBUTE_BOLD, TextStyle.CHARACTER_ATTRIBUTE_ITALIC,
+ TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE, TextStyle.CHARACTER_ATTRIBUTE_BLINK, TextStyle.CHARACTER_ATTRIBUTE_INVERSE,
+ TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE, TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH, TextStyle.CHARACTER_ATTRIBUTE_PROTECTED,
+ TextStyle.CHARACTER_ATTRIBUTE_DIM};
+
+ public void testEncodingSingle() {
+ for (int fx : ALL_EFFECTS) {
+ for (int fg = 0; fg < TextStyle.NUM_INDEXED_COLORS; fg++) {
+ for (int bg = 0; bg < TextStyle.NUM_INDEXED_COLORS; bg++) {
+ long encoded = TextStyle.encode(fg, bg, fx);
+ assertEquals(fg, TextStyle.decodeForeColor(encoded));
+ assertEquals(bg, TextStyle.decodeBackColor(encoded));
+ assertEquals(fx, TextStyle.decodeEffect(encoded));
+ }
+ }
+ }
+ }
+
+ public void testEncoding24Bit() {
+ int[] values = {255, 240, 127, 1, 0};
+ for (int red : values) {
+ for (int green : values) {
+ for (int blue : values) {
+ int argb = 0xFF000000 | (red << 16) | (green << 8) | blue;
+ long encoded = TextStyle.encode(argb, 0, 0);
+ assertEquals(argb, TextStyle.decodeForeColor(encoded));
+ encoded = TextStyle.encode(0, argb, 0);
+ assertEquals(argb, TextStyle.decodeBackColor(encoded));
+ }
+ }
+ }
+ }
+
+
+ public void testEncodingCombinations() {
+ for (int f1 : ALL_EFFECTS) {
+ for (int f2 : ALL_EFFECTS) {
+ int combined = f1 | f2;
+ assertEquals(combined, TextStyle.decodeEffect(TextStyle.encode(0, 0, combined)));
+ }
+ }
+ }
+
+ public void testEncodingStrikeThrough() {
+ long encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND,
+ TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH);
+ assertTrue((TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0);
+ }
+
+ public void testEncodingProtected() {
+ long encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND,
+ TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH);
+ assertEquals(0, (TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED));
+ encoded = TextStyle.encode(TextStyle.COLOR_INDEX_FOREGROUND, TextStyle.COLOR_INDEX_BACKGROUND,
+ TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH | TextStyle.CHARACTER_ATTRIBUTE_PROTECTED);
+ assertTrue((TextStyle.decodeEffect(encoded) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) != 0);
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/UnicodeInputTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/UnicodeInputTest.java
new file mode 100644
index 0000000..2733190
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/UnicodeInputTest.java
@@ -0,0 +1,136 @@
+package com.termux.terminal;
+
+import java.io.UnsupportedEncodingException;
+
+public class UnicodeInputTest extends TerminalTestCase {
+
+ public void testIllFormedUtf8SuccessorByteNotConsumed() throws Exception {
+ // The Unicode Standard Version 6.2 – Core Specification (http://www.unicode.org/versions/Unicode6.2.0/ch03.pdf):
+ // "If the converter encounters an ill-formed UTF-8 code unit sequence which starts with a valid first byte, but which does not
+ // continue with valid successor bytes (see Table 3-7), it must not consume the successor bytes as part of the ill-formed
+ // subsequence whenever those successor bytes themselves constitute part of a well-formed UTF-8 code unit subsequence."
+ withTerminalSized(5, 5);
+ mTerminal.append(new byte[]{(byte) 0b11101111, (byte) 'a'}, 2);
+ assertLineIs(0, ((char) TerminalEmulator.UNICODE_REPLACEMENT_CHAR) + "a ");
+
+ // https://code.google.com/p/chromium/issues/detail?id=212704
+ byte[] input = new byte[]{
+ (byte) 0x61, (byte) 0xF1,
+ (byte) 0x80, (byte) 0x80,
+ (byte) 0xe1, (byte) 0x80,
+ (byte) 0xc2, (byte) 0x62,
+ (byte) 0x80, (byte) 0x63,
+ (byte) 0x80, (byte) 0xbf,
+ (byte) 0x64
+ };
+ withTerminalSized(10, 2);
+ mTerminal.append(input, input.length);
+ assertLinesAre("a\uFFFD\uFFFD\uFFFDb\uFFFDc\uFFFD\uFFFDd", " ");
+
+ // Surrogate pairs.
+ withTerminalSized(5, 2);
+ input = new byte[]{
+ (byte) 0xed, (byte) 0xa0,
+ (byte) 0x80, (byte) 0xed,
+ (byte) 0xad, (byte) 0xbf,
+ (byte) 0xed, (byte) 0xae,
+ (byte) 0x80, (byte) 0xed,
+ (byte) 0xbf, (byte) 0xbf
+ };
+ mTerminal.append(input, input.length);
+ assertLinesAre("\uFFFD\uFFFD\uFFFD\uFFFD ", " ");
+
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=746900: "with this patch 0xe0 0x80 is decoded as two U+FFFDs,
+ // but 0xe0 0xa0 is decoded as a single U+FFFD, and this is correct according to the "Best Practices", but IE
+ // and Chrome (Version 22.0.1229.94) decode both of them as two U+FFFDs. Opera 12.11 decodes both of them as
+ // one U+FFFD".
+ withTerminalSized(5, 2);
+ input = new byte[]{(byte) 0xe0, (byte) 0xa0, ' '};
+ mTerminal.append(input, input.length);
+ assertLinesAre("\uFFFD ", " ");
+
+ // withTerminalSized(5, 2);
+ // input = new byte[]{(byte) 0xe0, (byte) 0x80, 'a'};
+ // mTerminal.append(input, input.length);
+ // assertLinesAre("\uFFFD\uFFFDa ", " ");
+ }
+
+ public void testUnassignedCodePoint() throws UnsupportedEncodingException {
+ withTerminalSized(3, 3);
+ // UTF-8 for U+C2541, an unassigned code point:
+ byte[] b = new byte[]{(byte) 0xf3, (byte) 0x82, (byte) 0x95, (byte) 0x81};
+ mTerminal.append(b, b.length);
+ enterString("Y");
+ assertEquals(1, Character.charCount(TerminalEmulator.UNICODE_REPLACEMENT_CHAR));
+ assertLineStartsWith(0, TerminalEmulator.UNICODE_REPLACEMENT_CHAR, (int) 'Y', ' ');
+ }
+
+ public void testStuff() {
+ withTerminalSized(80, 24);
+ byte[] b = new byte[]{(byte) 0xf3, (byte) 0x82, (byte) 0x95, (byte) 0x81, (byte) 0x61, (byte) 0x38, (byte) 0xe7, (byte) 0x8f,
+ (byte) 0xae, (byte) 0xc2, (byte) 0x9f, (byte) 0xe8, (byte) 0xa0, (byte) 0x9f, (byte) 0xe8, (byte) 0x8c, (byte) 0xa4,
+ (byte) 0xed, (byte) 0x93, (byte) 0x89, (byte) 0xef, (byte) 0xbf, (byte) 0xbd, (byte) 0x42, (byte) 0xc2, (byte) 0x9b,
+ (byte) 0xe6, (byte) 0x87, (byte) 0x89, (byte) 0x5a};
+ mTerminal.append(b, b.length);
+ }
+
+ public void testSimpleCombining() throws Exception {
+ withTerminalSized(3, 2).enterString(" a\u0302 ").assertLinesAre(" a\u0302 ", " ");
+ }
+
+ public void testCombiningCharacterInFirstColumn() throws Exception {
+ withTerminalSized(5, 3).enterString("test\r\nhi\r\n").assertLinesAre("test ", "hi ", " ");
+
+ // U+0302 is COMBINING CIRCUMFLEX ACCENT. Test case from mosh (http://mosh.mit.edu/).
+ withTerminalSized(5, 5).enterString("test\r\nabc\r\n\u0302\r\ndef\r\n");
+ assertLinesAre("test ", "abc ", " \u0302 ", "def ", " ");
+ }
+
+ public void testCombiningCharacterInLastColumn() throws Exception {
+ withTerminalSized(3, 2).enterString(" a\u0302").assertLinesAre(" a\u0302", " ");
+ withTerminalSized(3, 2).enterString(" à̲").assertLinesAre(" à̲", " ");
+ withTerminalSized(3, 2).enterString("Aà̲F").assertLinesAre("Aà̲F", " ");
+ }
+
+ public void testWideCharacterInLastColumn() throws Exception {
+ withTerminalSized(3, 2).enterString(" 枝\u0302").assertLinesAre(" ", "枝\u0302 ");
+
+ withTerminalSized(3, 2).enterString(" 枝").assertLinesAre(" 枝", " ").assertCursorAt(0, 2);
+ enterString("a").assertLinesAre(" 枝", "a ");
+ }
+
+ public void testWideCharacterDeletion() throws Exception {
+ // CSI Ps D Cursor Backward Ps Times
+ withTerminalSized(3, 2).enterString("枝\033[Da").assertLinesAre(" a ", " ");
+ withTerminalSized(3, 2).enterString("枝\033[2Da").assertLinesAre("a ", " ");
+ withTerminalSized(3, 2).enterString("枝\033[2D枝").assertLinesAre("枝 ", " ");
+ withTerminalSized(3, 2).enterString("枝\033[1D枝").assertLinesAre(" 枝", " ");
+ withTerminalSized(5, 2).enterString(" 枝 \033[Da").assertLinesAre(" 枝a ", " ");
+ withTerminalSized(5, 2).enterString("a \033[D\u0302").assertLinesAre("a\u0302 ", " ");
+ withTerminalSized(5, 2).enterString("枝 \033[D\u0302").assertLinesAre("枝\u0302 ", " ");
+ enterString("Z").assertLinesAre("枝\u0302Z ", " ");
+ enterString("\033[D ").assertLinesAre("枝\u0302 ", " ");
+ // Go back two columns, standing at the second half of the wide character:
+ enterString("\033[2DU").assertLinesAre(" U ", " ");
+ }
+
+ public void testWideCharOverwriting() {
+ withTerminalSized(3, 2).enterString("abc\033[3D枝").assertLinesAre("枝c", " ");
+ }
+
+ public void testOverlongUtf8Encoding() throws Exception {
+ // U+0020 should be encoded as 0x20, 0xc0 0xa0 is an overlong encoding
+ // so should be replaced with the replacement char U+FFFD.
+ withTerminalSized(5, 5).mTerminal.append(new byte[]{(byte) 0xc0, (byte) 0xa0, 'Y'}, 3);
+ assertLineIs(0, "\uFFFDY ");
+ }
+
+ public void testWideCharacterWithoutWrapping() throws Exception {
+ // With wraparound disabled. The behaviour when a wide character is output with cursor in
+ // the last column when autowrap is disabled is not obvious, but we expect the wide
+ // character to be ignored here.
+ withTerminalSized(3, 3).enterString("\033[?7l").enterString("枝枝枝").assertLinesAre("枝 ", " ", " ");
+ enterString("a枝").assertLinesAre("枝a", " ", " ");
+ }
+
+}
diff --git a/android/terminal-emulator/src/test/java/com/termux/terminal/WcWidthTest.java b/android/terminal-emulator/src/test/java/com/termux/terminal/WcWidthTest.java
new file mode 100644
index 0000000..c0d9a0b
--- /dev/null
+++ b/android/terminal-emulator/src/test/java/com/termux/terminal/WcWidthTest.java
@@ -0,0 +1,81 @@
+package com.termux.terminal;
+
+import junit.framework.TestCase;
+
+public class WcWidthTest extends TestCase {
+
+ private static void assertWidthIs(int expectedWidth, int codePoint) {
+ int wcWidth = WcWidth.width(codePoint);
+ assertEquals(expectedWidth, wcWidth);
+ }
+
+ public void testPrintableAscii() {
+ for (int i = 0x20; i <= 0x7E; i++) {
+ assertWidthIs(1, i);
+ }
+ }
+
+ public void testSomeWidthOne() {
+ assertWidthIs(1, 'å');
+ assertWidthIs(1, 'ä');
+ assertWidthIs(1, 'ö');
+ assertWidthIs(1, 0x23F2);
+ }
+
+ public void testSomeWide() {
+ assertWidthIs(2, 'A');
+ assertWidthIs(2, 'B');
+ assertWidthIs(2, 'C');
+ assertWidthIs(2, '中');
+ assertWidthIs(2, '文');
+
+ assertWidthIs(2, 0x679C);
+ assertWidthIs(2, 0x679D);
+
+ assertWidthIs(2, 0x2070E);
+ assertWidthIs(2, 0x20731);
+
+ assertWidthIs(1, 0x1F781);
+ }
+
+ public void testSomeNonWide() {
+ assertWidthIs(1, 0x1D11E);
+ assertWidthIs(1, 0x1D11F);
+ }
+
+ public void testCombining() {
+ assertWidthIs(0, 0x0302);
+ assertWidthIs(0, 0x0308);
+ assertWidthIs(0, 0xFE0F);
+ }
+
+ public void testWordJoiner() {
+ // https://en.wikipedia.org/wiki/Word_joiner
+ // The word joiner (WJ) is a code point in Unicode used to separate words when using scripts
+ // that do not use explicit spacing. It is encoded since Unicode version 3.2
+ // (released in 2002) as U+2060 WORD JOINER (HTML ).
+ // The word joiner does not produce any space, and prohibits a line break at its position.
+ assertWidthIs(0, 0x2060);
+ }
+
+ public void testSofthyphen() {
+ // http://osdir.com/ml/internationalization.linux/2003-05/msg00006.html:
+ // "Existing implementation practice in terminals is that the SOFT HYPHEN is
+ // a spacing graphical character, and the purpose of my wcwidth() was to
+ // predict the advancement of the cursor position after a string is sent to
+ // a terminal. Hence, I have no choice but to keep wcwidth(SOFT HYPHEN) = 1.
+ // VT100-style terminals do not hyphenate."
+ assertWidthIs(1, 0x00AD);
+ }
+
+ public void testHangul() {
+ assertWidthIs(1, 0x11A3);
+ }
+
+ public void testEmojis() {
+ assertWidthIs(2, 0x1F428); // KOALA.
+ assertWidthIs(2, 0x231a); // WATCH.
+ assertWidthIs(2, 0x1F643); // UPSIDE-DOWN FACE (Unicode 8).
+ }
+
+}
diff --git a/android/terminal-view/build.gradle b/android/terminal-view/build.gradle
new file mode 100644
index 0000000..ead2885
--- /dev/null
+++ b/android/terminal-view/build.gradle
@@ -0,0 +1,28 @@
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion 35
+ namespace = "com.termux.view"
+
+ defaultConfig {
+ minSdkVersion 24
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+}
+
+dependencies {
+ api project(":terminal-emulator")
+ implementation "androidx.annotation:annotation:1.3.0"
+ testImplementation "junit:junit:4.13.2"
+}
diff --git a/android/terminal-view/proguard-rules.pro b/android/terminal-view/proguard-rules.pro
new file mode 100644
index 0000000..2e56e60
--- /dev/null
+++ b/android/terminal-view/proguard-rules.pro
@@ -0,0 +1,25 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /Users/fornwall/lib/android-sdk/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Add any project specific keep options here:
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/android/terminal-view/src/main/AndroidManifest.xml b/android/terminal-view/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..bdae66c
--- /dev/null
+++ b/android/terminal-view/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/android/terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java b/android/terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java
new file mode 100644
index 0000000..f7fc9d2
--- /dev/null
+++ b/android/terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java
@@ -0,0 +1,112 @@
+package com.termux.view;
+
+import android.content.Context;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+
+/** A combination of {@link GestureDetector} and {@link ScaleGestureDetector}. */
+final class GestureAndScaleRecognizer {
+
+ public interface Listener {
+ boolean onSingleTapUp(MotionEvent e);
+
+ boolean onDoubleTap(MotionEvent e);
+
+ boolean onScroll(MotionEvent e2, float dx, float dy);
+
+ boolean onFling(MotionEvent e, float velocityX, float velocityY);
+
+ boolean onScale(float focusX, float focusY, float scale);
+
+ boolean onDown(float x, float y);
+
+ boolean onUp(MotionEvent e);
+
+ void onLongPress(MotionEvent e);
+ }
+
+ private final GestureDetector mGestureDetector;
+ private final ScaleGestureDetector mScaleDetector;
+ final Listener mListener;
+ boolean isAfterLongPress;
+
+ public GestureAndScaleRecognizer(Context context, Listener listener) {
+ mListener = listener;
+
+ mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
+ @Override
+ public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) {
+ return mListener.onScroll(e2, dx, dy);
+ }
+
+ @Override
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+ return mListener.onFling(e2, velocityX, velocityY);
+ }
+
+ @Override
+ public boolean onDown(MotionEvent e) {
+ return mListener.onDown(e.getX(), e.getY());
+ }
+
+ @Override
+ public void onLongPress(MotionEvent e) {
+ mListener.onLongPress(e);
+ isAfterLongPress = true;
+ }
+ }, null, true /* ignoreMultitouch */);
+
+ mGestureDetector.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() {
+ @Override
+ public boolean onSingleTapConfirmed(MotionEvent e) {
+ return mListener.onSingleTapUp(e);
+ }
+
+ @Override
+ public boolean onDoubleTap(MotionEvent e) {
+ return mListener.onDoubleTap(e);
+ }
+
+ @Override
+ public boolean onDoubleTapEvent(MotionEvent e) {
+ return true;
+ }
+ });
+
+ mScaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
+ @Override
+ public boolean onScaleBegin(ScaleGestureDetector detector) {
+ return true;
+ }
+
+ @Override
+ public boolean onScale(ScaleGestureDetector detector) {
+ return mListener.onScale(detector.getFocusX(), detector.getFocusY(), detector.getScaleFactor());
+ }
+ });
+ mScaleDetector.setQuickScaleEnabled(false);
+ }
+
+ public void onTouchEvent(MotionEvent event) {
+ mGestureDetector.onTouchEvent(event);
+ mScaleDetector.onTouchEvent(event);
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ isAfterLongPress = false;
+ break;
+ case MotionEvent.ACTION_UP:
+ if (!isAfterLongPress) {
+ // This behaviour is desired when in e.g. vim with mouse events, where we do not
+ // want to move the cursor when lifting finger after a long press.
+ mListener.onUp(event);
+ }
+ break;
+ }
+ }
+
+ public boolean isInProgress() {
+ return mScaleDetector.isInProgress();
+ }
+
+}
diff --git a/android/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java b/android/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java
new file mode 100644
index 0000000..a4bef7d
--- /dev/null
+++ b/android/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java
@@ -0,0 +1,249 @@
+package com.termux.view;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.Typeface;
+
+import com.termux.terminal.TerminalBuffer;
+import com.termux.terminal.TerminalEmulator;
+import com.termux.terminal.TerminalRow;
+import com.termux.terminal.TextStyle;
+import com.termux.terminal.WcWidth;
+
+/**
+ * Renderer of a {@link TerminalEmulator} into a {@link Canvas}.
+ *
+ * Saves font metrics, so needs to be recreated each time the typeface or font size changes.
+ */
+public final class TerminalRenderer {
+
+ final int mTextSize;
+ final Typeface mTypeface;
+ private final Paint mTextPaint = new Paint();
+
+ /** The width of a single mono spaced character obtained by {@link Paint#measureText(String)} on a single 'X'. */
+ final float mFontWidth;
+ /** The {@link Paint#getFontSpacing()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
+ final int mFontLineSpacing;
+ /** The {@link Paint#ascent()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
+ private final int mFontAscent;
+ /** The {@link #mFontLineSpacing} + {@link #mFontAscent}. */
+ final int mFontLineSpacingAndAscent;
+
+ private final float[] asciiMeasures = new float[127];
+
+ public TerminalRenderer(int textSize, Typeface typeface) {
+ mTextSize = textSize;
+ mTypeface = typeface;
+
+ mTextPaint.setTypeface(typeface);
+ mTextPaint.setAntiAlias(true);
+ mTextPaint.setTextSize(textSize);
+
+ mFontLineSpacing = (int) Math.ceil(mTextPaint.getFontSpacing());
+ mFontAscent = (int) Math.ceil(mTextPaint.ascent());
+ mFontLineSpacingAndAscent = mFontLineSpacing + mFontAscent;
+ mFontWidth = mTextPaint.measureText("X");
+
+ StringBuilder sb = new StringBuilder(" ");
+ for (int i = 0; i < asciiMeasures.length; i++) {
+ sb.setCharAt(0, (char) i);
+ asciiMeasures[i] = mTextPaint.measureText(sb, 0, 1);
+ }
+ }
+
+ /** Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection. */
+ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow,
+ int selectionY1, int selectionY2, int selectionX1, int selectionX2) {
+ final boolean reverseVideo = mEmulator.isReverseVideo();
+ final int endRow = topRow + mEmulator.mRows;
+ final int columns = mEmulator.mColumns;
+ final int cursorCol = mEmulator.getCursorCol();
+ final int cursorRow = mEmulator.getCursorRow();
+ final boolean cursorVisible = mEmulator.shouldCursorBeVisible();
+ final TerminalBuffer screen = mEmulator.getScreen();
+ final int[] palette = mEmulator.mColors.mCurrentColors;
+ final int cursorShape = mEmulator.getCursorStyle();
+
+ if (reverseVideo)
+ canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC);
+
+ float heightOffset = mFontLineSpacingAndAscent;
+ for (int row = topRow; row < endRow; row++) {
+ heightOffset += mFontLineSpacing;
+
+ final int cursorX = (row == cursorRow && cursorVisible) ? cursorCol : -1;
+ int selx1 = -1, selx2 = -1;
+ if (row >= selectionY1 && row <= selectionY2) {
+ if (row == selectionY1) selx1 = selectionX1;
+ selx2 = (row == selectionY2) ? selectionX2 : mEmulator.mColumns;
+ }
+
+ TerminalRow lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row));
+ final char[] line = lineObject.mText;
+ final int charsUsedInLine = lineObject.getSpaceUsed();
+
+ long lastRunStyle = 0;
+ boolean lastRunInsideCursor = false;
+ boolean lastRunInsideSelection = false;
+ int lastRunStartColumn = -1;
+ int lastRunStartIndex = 0;
+ boolean lastRunFontWidthMismatch = false;
+ int currentCharIndex = 0;
+ float measuredWidthForRun = 0.f;
+
+ for (int column = 0; column < columns; ) {
+ final char charAtIndex = line[currentCharIndex];
+ final boolean charIsHighsurrogate = Character.isHighSurrogate(charAtIndex);
+ final int charsForCodePoint = charIsHighsurrogate ? 2 : 1;
+ final int codePoint = charIsHighsurrogate ? Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) : charAtIndex;
+ final int codePointWcWidth = WcWidth.width(codePoint);
+ final boolean insideCursor = (cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1));
+ final boolean insideSelection = column >= selx1 && column <= selx2;
+ final long style = lineObject.getStyle(column);
+
+ // 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
+ // smileys which android font renders as wide.
+ // If this is detected, we draw this code point scaled to match what wcwidth() expects.
+ final float measuredCodePointWidth = (codePoint < asciiMeasures.length) ? asciiMeasures[codePoint] : mTextPaint.measureText(line,
+ currentCharIndex, charsForCodePoint);
+ final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01;
+
+ if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection || fontWidthMismatch || lastRunFontWidthMismatch) {
+ if (column == 0) {
+ // Skip first column as there is nothing to draw, just record the current style.
+ } else {
+ final int columnWidthSinceLastRun = column - lastRunStartColumn;
+ final int charsSinceLastRun = currentCharIndex - lastRunStartIndex;
+ int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0;
+ boolean invertCursorTextColor = false;
+ if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) {
+ invertCursorTextColor = true;
+ }
+ drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun,
+ lastRunStartIndex, charsSinceLastRun, measuredWidthForRun,
+ cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection);
+ }
+ measuredWidthForRun = 0.f;
+ lastRunStyle = style;
+ lastRunInsideCursor = insideCursor;
+ lastRunInsideSelection = insideSelection;
+ lastRunStartColumn = column;
+ lastRunStartIndex = currentCharIndex;
+ lastRunFontWidthMismatch = fontWidthMismatch;
+ }
+ measuredWidthForRun += measuredCodePointWidth;
+ column += codePointWcWidth;
+ currentCharIndex += charsForCodePoint;
+ while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) {
+ // Eat combining chars so that they are treated as part of the last non-combining code point,
+ // instead of e.g. being considered inside the cursor in the next run.
+ currentCharIndex += Character.isHighSurrogate(line[currentCharIndex]) ? 2 : 1;
+ }
+ }
+
+ final int columnWidthSinceLastRun = columns - lastRunStartColumn;
+ final int charsSinceLastRun = currentCharIndex - lastRunStartIndex;
+ int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0;
+ boolean invertCursorTextColor = false;
+ if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) {
+ invertCursorTextColor = true;
+ }
+ drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun,
+ measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection);
+ }
+ }
+
+ 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) {
+ 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 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;
+
+ if ((foreColor & 0xff000000) != 0xff000000) {
+ // Let bold have bright colors if applicable (one of the first 8):
+ if (bold && foreColor >= 0 && foreColor < 8) foreColor += 8;
+ foreColor = palette[foreColor];
+ }
+
+ if ((backColor & 0xff000000) != 0xff000000) {
+ backColor = palette[backColor];
+ }
+
+ // Reverse video here if _one and only one_ of the reverse flags are set:
+ final boolean reverseVideoHere = reverseVideo ^ (effect & (TextStyle.CHARACTER_ATTRIBUTE_INVERSE)) != 0;
+ if (reverseVideoHere) {
+ int tmp = foreColor;
+ foreColor = backColor;
+ backColor = tmp;
+ }
+
+ float left = startColumn * mFontWidth;
+ float right = left + runWidthColumns * mFontWidth;
+
+ mes = mes / mFontWidth;
+ boolean savedMatrix = false;
+ if (Math.abs(mes - runWidthColumns) > 0.01) {
+ canvas.save();
+ canvas.scale(runWidthColumns / mes, 1.f);
+ left *= mes / runWidthColumns;
+ right *= mes / runWidthColumns;
+ savedMatrix = true;
+ }
+
+ if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) {
+ // Only draw non-default background.
+ mTextPaint.setColor(backColor);
+ canvas.drawRect(left, y - mFontLineSpacingAndAscent + mFontAscent, right, y, mTextPaint);
+ }
+
+ if (cursor != 0) {
+ mTextPaint.setColor(cursor);
+ float cursorHeight = mFontLineSpacingAndAscent - mFontAscent;
+ if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4.;
+ else if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4.;
+ canvas.drawRect(left, y - cursorHeight, right, y, mTextPaint);
+ }
+
+ if ((effect & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) {
+ if (dim) {
+ int red = (0xFF & (foreColor >> 16));
+ int green = (0xFF & (foreColor >> 8));
+ int blue = (0xFF & foreColor);
+ // Dim color handling used by libvte which in turn took it from xterm
+ // (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267):
+ red = red * 2 / 3;
+ green = green * 2 / 3;
+ blue = blue * 2 / 3;
+ foreColor = 0xFF000000 + (red << 16) + (green << 8) + blue;
+ }
+
+ mTextPaint.setFakeBoldText(bold);
+ mTextPaint.setUnderlineText(underline);
+ mTextPaint.setTextSkewX(italic ? -0.35f : 0.f);
+ mTextPaint.setStrikeThruText(strikeThrough);
+ mTextPaint.setColor(foreColor);
+
+ // The text alignment is the default Paint.Align.LEFT.
+ canvas.drawTextRun(text, startCharIndex, runWidthChars, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, false, mTextPaint);
+ }
+
+ if (savedMatrix) canvas.restore();
+ }
+
+ public float getFontWidth() {
+ return mFontWidth;
+ }
+
+ public int getFontLineSpacing() {
+ return mFontLineSpacing;
+ }
+}
diff --git a/android/terminal-view/src/main/java/com/termux/view/TerminalView.java b/android/terminal-view/src/main/java/com/termux/view/TerminalView.java
new file mode 100644
index 0000000..19b50f4
--- /dev/null
+++ b/android/terminal-view/src/main/java/com/termux/view/TerminalView.java
@@ -0,0 +1,1502 @@
+package com.termux.view;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Typeface;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.ActionMode;
+import android.view.HapticFeedbackConstants;
+import android.view.InputDevice;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewTreeObserver;
+import android.view.accessibility.AccessibilityManager;
+import android.view.autofill.AutofillManager;
+import android.view.autofill.AutofillValue;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.Scroller;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import com.termux.terminal.KeyHandler;
+import com.termux.terminal.TerminalEmulator;
+import com.termux.terminal.TerminalSession;
+import com.termux.view.textselection.TextSelectionCursorController;
+
+/** View displaying and interacting with a {@link TerminalSession}. */
+public final class TerminalView extends View {
+
+ /** Log terminal view key and IME events. */
+ private static boolean TERMINAL_VIEW_KEY_LOGGING_ENABLED = false;
+
+ /** The currently displayed terminal session, whose emulator is {@link #mEmulator}. */
+ public TerminalSession mTermSession;
+ /** Our terminal emulator whose session is {@link #mTermSession}. */
+ public TerminalEmulator mEmulator;
+
+ public TerminalRenderer mRenderer;
+
+ public TerminalViewClient mClient;
+
+ private TextSelectionCursorController mTextSelectionCursorController;
+
+ private Handler mTerminalCursorBlinkerHandler;
+ private TerminalCursorBlinkerRunnable mTerminalCursorBlinkerRunnable;
+ private int mTerminalCursorBlinkerRate;
+ private boolean mCursorInvisibleIgnoreOnce;
+ public static final int TERMINAL_CURSOR_BLINK_RATE_MIN = 100;
+ public static final int TERMINAL_CURSOR_BLINK_RATE_MAX = 2000;
+
+ /** The top row of text to display. Ranges from -activeTranscriptRows to 0. */
+ int mTopRow;
+ int[] mDefaultSelectors = new int[]{-1,-1,-1,-1};
+
+ float mScaleFactor = 1.f;
+ final GestureAndScaleRecognizer mGestureRecognizer;
+
+ /** Keep track of where mouse touch event started which we report as mouse scroll. */
+ private int mMouseScrollStartX = -1, mMouseScrollStartY = -1;
+ /** Keep track of the time when a touch event leading to sending mouse scroll events started. */
+ private long mMouseStartDownTime = -1;
+
+ final Scroller mScroller;
+
+ /** What was left in from scrolling movement. */
+ float mScrollRemainder;
+
+ /** If non-zero, this is the last unicode code point received if that was a combining character. */
+ int mCombiningAccent;
+
+ /**
+ * The current AutoFill type returned for {@link View#getAutofillType()} by {@link #getAutofillType()}.
+ *
+ * The default is {@link #AUTOFILL_TYPE_NONE} so that AutoFill UI, like toolbar above keyboard
+ * is not shown automatically, like on Activity starts/View create. This value should be updated
+ * to required value, like {@link #AUTOFILL_TYPE_TEXT} before calling
+ * {@link AutofillManager#requestAutofill(View)} so that AutoFill UI shows. The updated value
+ * set will automatically be restored to {@link #AUTOFILL_TYPE_NONE} in
+ * {@link #autofill(AutofillValue)} so that AutoFill UI isn't shown anymore by calling
+ * {@link #resetAutoFill()}.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ private int mAutoFillType = AUTOFILL_TYPE_NONE;
+
+ /**
+ * The current AutoFill type returned for {@link View#getImportantForAutofill()} by
+ * {@link #getImportantForAutofill()}.
+ *
+ * The default is {@link #IMPORTANT_FOR_AUTOFILL_NO} so that view is not considered important
+ * for AutoFill. This value should be updated to required value, like
+ * {@link #IMPORTANT_FOR_AUTOFILL_YES} before calling {@link AutofillManager#requestAutofill(View)}
+ * so that Android and apps consider the view as important for AutoFill to process the request.
+ * The updated value set will automatically be restored to {@link #IMPORTANT_FOR_AUTOFILL_NO} in
+ * {@link #autofill(AutofillValue)} by calling {@link #resetAutoFill()}.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ private int mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_NO;
+
+ /**
+ * The current AutoFill hints returned for {@link View#getAutofillHints()} ()} by {@link #getAutofillHints()} ()}.
+ *
+ * The default is an empty `string[]`. This value should be updated to required value. The
+ * updated value set will automatically be restored an empty `string[]` in
+ * {@link #autofill(AutofillValue)} by calling {@link #resetAutoFill()}.
+ */
+ private String[] mAutoFillHints = new String[0];
+
+ private final boolean mAccessibilityEnabled;
+
+ /** The {@link KeyEvent} is generated from a virtual keyboard, like manually with the {@link KeyEvent#KeyEvent(int, int)} constructor. */
+ public final static int KEY_EVENT_SOURCE_VIRTUAL_KEYBOARD = KeyCharacterMap.VIRTUAL_KEYBOARD; // -1
+
+ /** The {@link KeyEvent} is generated from a non-physical device, like if 0 value is returned by {@link KeyEvent#getDeviceId()}. */
+ public final static int KEY_EVENT_SOURCE_SOFT_KEYBOARD = 0;
+
+ private static final String LOG_TAG = "TerminalView";
+
+ public TerminalView(Context context, AttributeSet attributes) { // NO_UCD (unused code)
+ super(context, attributes);
+ mGestureRecognizer = new GestureAndScaleRecognizer(context, new GestureAndScaleRecognizer.Listener() {
+
+ boolean scrolledWithFinger;
+
+ @Override
+ public boolean onUp(MotionEvent event) {
+ mScrollRemainder = 0.0f;
+ if (mEmulator != null && mEmulator.isMouseTrackingActive() && !event.isFromSource(InputDevice.SOURCE_MOUSE) && !isSelectingText() && !scrolledWithFinger) {
+ // Quick event processing when mouse tracking is active - do not wait for check of double tapping
+ // for zooming.
+ sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, true);
+ sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, false);
+ return true;
+ }
+ scrolledWithFinger = false;
+ return false;
+ }
+
+ @Override
+ public boolean onSingleTapUp(MotionEvent event) {
+ if (mEmulator == null) return true;
+
+ if (isSelectingText()) {
+ stopTextSelectionMode();
+ return true;
+ }
+ requestFocus();
+ mClient.onSingleTapUp(event);
+ return true;
+ }
+
+ @Override
+ public boolean onScroll(MotionEvent e, float distanceX, float distanceY) {
+ if (mEmulator == null) return true;
+ if (mEmulator.isMouseTrackingActive() && e.isFromSource(InputDevice.SOURCE_MOUSE)) {
+ // If moving with mouse pointer while pressing button, report that instead of scroll.
+ // This means that we never report moving with button press-events for touch input,
+ // since we cannot just start sending these events without a starting press event,
+ // which we do not do for touch input, only mouse in onTouchEvent().
+ sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true);
+ } else {
+ scrolledWithFinger = true;
+ distanceY += mScrollRemainder;
+ int deltaRows = (int) (distanceY / mRenderer.mFontLineSpacing);
+ mScrollRemainder = distanceY - deltaRows * mRenderer.mFontLineSpacing;
+ doScroll(e, deltaRows);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onScale(float focusX, float focusY, float scale) {
+ if (mEmulator == null || isSelectingText()) return true;
+ mScaleFactor *= scale;
+ mScaleFactor = mClient.onScale(mScaleFactor);
+ return true;
+ }
+
+ @Override
+ public boolean onFling(final MotionEvent e2, float velocityX, float velocityY) {
+ if (mEmulator == null) return true;
+ // Do not start scrolling until last fling has been taken care of:
+ if (!mScroller.isFinished()) return true;
+
+ final boolean mouseTrackingAtStartOfFling = mEmulator.isMouseTrackingActive();
+ float SCALE = 0.25f;
+ if (mouseTrackingAtStartOfFling) {
+ mScroller.fling(0, 0, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.mRows / 2, mEmulator.mRows / 2);
+ } else {
+ mScroller.fling(0, mTopRow, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.getScreen().getActiveTranscriptRows(), 0);
+ }
+
+ post(new Runnable() {
+ private int mLastY = 0;
+
+ @Override
+ public void run() {
+ if (mouseTrackingAtStartOfFling != mEmulator.isMouseTrackingActive()) {
+ mScroller.abortAnimation();
+ return;
+ }
+ if (mScroller.isFinished()) return;
+ boolean more = mScroller.computeScrollOffset();
+ int newY = mScroller.getCurrY();
+ int diff = mouseTrackingAtStartOfFling ? (newY - mLastY) : (newY - mTopRow);
+ doScroll(e2, diff);
+ mLastY = newY;
+ if (more) post(this);
+ }
+ });
+
+ return true;
+ }
+
+ @Override
+ public boolean onDown(float x, float y) {
+ // Why is true not returned here?
+ // https://developer.android.com/training/gestures/detector.html#detect-a-subset-of-supported-gestures
+ // Although setting this to true still does not solve the following errors when long pressing in terminal view text area
+ // ViewDragHelper: Ignoring pointerId=0 because ACTION_DOWN was not received for this pointer before ACTION_MOVE
+ // Commenting out the call to mGestureDetector.onTouchEvent(event) in GestureAndScaleRecognizer#onTouchEvent() removes
+ // the error logging, so issue is related to GestureDetector
+ return false;
+ }
+
+ @Override
+ public boolean onDoubleTap(MotionEvent event) {
+ // Do not treat is as a single confirmed tap - it may be followed by zoom.
+ return false;
+ }
+
+ @Override
+ public void onLongPress(MotionEvent event) {
+ if (mGestureRecognizer.isInProgress()) return;
+ if (mClient.onLongPress(event)) return;
+ if (!isSelectingText()) {
+ performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+ startTextSelectionMode(event);
+ }
+ }
+ });
+ mScroller = new Scroller(context);
+ AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+ mAccessibilityEnabled = am.isEnabled();
+ }
+
+
+
+ /**
+ * @param client The {@link TerminalViewClient} interface implementation to allow
+ * for communication between {@link TerminalView} and its client.
+ */
+ public void setTerminalViewClient(TerminalViewClient client) {
+ this.mClient = client;
+ }
+
+ /**
+ * Sets whether terminal view key logging is enabled or not.
+ *
+ * @param value The boolean value that defines the state.
+ */
+ public void setIsTerminalViewKeyLoggingEnabled(boolean value) {
+ TERMINAL_VIEW_KEY_LOGGING_ENABLED = value;
+ }
+
+
+
+ /**
+ * Attach a {@link TerminalSession} to this view.
+ *
+ * @param session The {@link TerminalSession} this view will be displaying.
+ */
+ public boolean attachSession(TerminalSession session) {
+ if (session == mTermSession) return false;
+ mTopRow = 0;
+
+ mTermSession = session;
+ mEmulator = null;
+ mCombiningAccent = 0;
+
+ updateSize();
+
+ // Wait with enabling the scrollbar until we have a terminal to get scroll position from.
+ setVerticalScrollBarEnabled(true);
+
+ return true;
+ }
+
+ @Override
+ public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
+ // Ensure that inputType is only set if TerminalView is selected view with the keyboard and
+ // an alternate view is not selected, like an EditText. This is necessary if an activity is
+ // initially started with the alternate view or if activity is returned to from another app
+ // and the alternate view was the one selected the last time.
+ if (mClient.isTerminalViewSelected()) {
+ int mode = mClient.getInputMode();
+ switch (mode) {
+ case 1: // TYPE_NULL - Strict Terminal
+ // Using InputType.NULL is the most correct input type and avoids issues with other hacks.
+ // Previous keyboard issues:
+ // https://github.com/termux/termux-packages/issues/25
+ // https://github.com/termux/termux-app/issues/87
+ // https://github.com/termux/termux-app/issues/126
+ // https://github.com/termux/termux-app/issues/137 (japanese chars and TYPE_NULL).
+ outAttrs.inputType = InputType.TYPE_NULL;
+ break;
+ case 2: // VISIBLE_PASSWORD - Legacy Workaround
+ // Some keyboards do not reset internal state on TYPE_NULL (Samsung stock keyboards).
+ // https://github.com/termux/termux-app/issues/686
+ // WARNING: Gboard treats "visible password" as ASCII-only, disabling CJK composition.
+ outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS;
+ break;
+ default: // 0 = DEFAULT (Recommended)
+ // TYPE_CLASS_TEXT signals a text field so all IMEs trigger properly.
+ // TYPE_TEXT_FLAG_NO_SUGGESTIONS disables autocomplete without blocking CJK composition.
+ outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS;
+ break;
+ }
+ } else {
+ // Corresponds to android:inputType="text"
+ outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL;
+ }
+
+ // Note that IME_ACTION_NONE cannot be used as that makes it impossible to input newlines using the on-screen
+ // keyboard on Android TV (see https://github.com/termux/termux-app/issues/221).
+ outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN;
+
+ return new BaseInputConnection(this, true) {
+
+ @Override
+ public boolean finishComposingText() {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "IME: finishComposingText()");
+ super.finishComposingText();
+
+ sendTextToTerminal(getEditable());
+ getEditable().clear();
+ return true;
+ }
+
+ @Override
+ public boolean commitText(CharSequence text, int newCursorPosition) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) {
+ mClient.logInfo(LOG_TAG, "IME: commitText(\"" + text + "\", " + newCursorPosition + ")");
+ }
+ super.commitText(text, newCursorPosition);
+
+ if (mEmulator == null) return true;
+
+ Editable content = getEditable();
+ sendTextToTerminal(content);
+ content.clear();
+ return true;
+ }
+
+ @Override
+ public boolean deleteSurroundingText(int leftLength, int rightLength) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) {
+ mClient.logInfo(LOG_TAG, "IME: deleteSurroundingText(" + leftLength + ", " + rightLength + ")");
+ }
+ // The stock Samsung keyboard with 'Auto check spelling' enabled sends leftLength > 1.
+ KeyEvent deleteKey = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL);
+ for (int i = 0; i < leftLength; i++) sendKeyEvent(deleteKey);
+ return super.deleteSurroundingText(leftLength, rightLength);
+ }
+
+ void sendTextToTerminal(CharSequence text) {
+ stopTextSelectionMode();
+ final int textLengthInChars = text.length();
+ for (int i = 0; i < textLengthInChars; i++) {
+ char firstChar = text.charAt(i);
+ int codePoint;
+ if (Character.isHighSurrogate(firstChar)) {
+ if (++i < textLengthInChars) {
+ codePoint = Character.toCodePoint(firstChar, text.charAt(i));
+ } else {
+ // At end of string, with no low surrogate following the high:
+ codePoint = TerminalEmulator.UNICODE_REPLACEMENT_CHAR;
+ }
+ } else {
+ codePoint = firstChar;
+ }
+
+ // Check onKeyDown() for details.
+ if (mClient.readShiftKey())
+ codePoint = Character.toUpperCase(codePoint);
+
+ boolean ctrlHeld = false;
+ if (codePoint <= 31 && codePoint != 27) {
+ if (codePoint == '\n') {
+ // The AOSP keyboard and descendants seems to send \n as text when the enter key is pressed,
+ // instead of a key event like most other keyboard apps. A terminal expects \r for the enter
+ // key (although when icrnl is enabled this doesn't make a difference - run 'stty -icrnl' to
+ // check the behaviour).
+ codePoint = '\r';
+ }
+
+ // E.g. penti keyboard for ctrl input.
+ ctrlHeld = true;
+ switch (codePoint) {
+ case 31:
+ codePoint = '_';
+ break;
+ case 30:
+ codePoint = '^';
+ break;
+ case 29:
+ codePoint = ']';
+ break;
+ case 28:
+ codePoint = '\\';
+ break;
+ default:
+ codePoint += 96;
+ break;
+ }
+ }
+
+ inputCodePoint(KEY_EVENT_SOURCE_SOFT_KEYBOARD, codePoint, ctrlHeld, false);
+ }
+ }
+
+ };
+ }
+
+ @Override
+ protected int computeVerticalScrollRange() {
+ return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows();
+ }
+
+ @Override
+ protected int computeVerticalScrollExtent() {
+ return mEmulator == null ? 1 : mEmulator.mRows;
+ }
+
+ @Override
+ protected int computeVerticalScrollOffset() {
+ return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows() + mTopRow - mEmulator.mRows;
+ }
+
+ public void onScreenUpdated() {
+ onScreenUpdated(false);
+ }
+
+ public void onScreenUpdated(boolean skipScrolling) {
+ if (mEmulator == null) return;
+
+ int rowsInHistory = mEmulator.getScreen().getActiveTranscriptRows();
+ if (mTopRow < -rowsInHistory) mTopRow = -rowsInHistory;
+
+ if (isSelectingText() || mEmulator.isAutoScrollDisabled()) {
+
+ // Do not scroll when selecting text.
+ int rowShift = mEmulator.getScrollCounter();
+ if (-mTopRow + rowShift > rowsInHistory) {
+ // .. unless we're hitting the end of history transcript, in which
+ // case we abort text selection and scroll to end.
+ if (isSelectingText())
+ stopTextSelectionMode();
+
+ if (mEmulator.isAutoScrollDisabled()) {
+ mTopRow = -rowsInHistory;
+ skipScrolling = true;
+ }
+ } else {
+ skipScrolling = true;
+ mTopRow -= rowShift;
+ decrementYTextSelectionCursors(rowShift);
+ }
+ }
+
+ if (!skipScrolling && mTopRow != 0) {
+ // Scroll down if not already there.
+ if (mTopRow < -3) {
+ // Awaken scroll bars only if scrolling a noticeable amount
+ // - we do not want visible scroll bars during normal typing
+ // of one row at a time.
+ awakenScrollBars();
+ }
+ mTopRow = 0;
+ }
+
+ mEmulator.clearScrollCounter();
+
+ invalidate();
+ if (mAccessibilityEnabled) setContentDescription(getText());
+ }
+
+ /** This must be called by the hosting activity in {@link Activity#onContextMenuClosed(Menu)}
+ * when context menu for the {@link TerminalView} is started by
+ * {@link TextSelectionCursorController#ACTION_MORE} is closed. */
+ public void onContextMenuClosed(Menu menu) {
+ // Unset the stored text since it shouldn't be used anymore and should be cleared from memory
+ unsetStoredSelectedText();
+ }
+
+ /**
+ * Sets the text size, which in turn sets the number of rows and columns.
+ *
+ * @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);
+ updateSize();
+ }
+
+ public void setTypeface(Typeface newTypeface) {
+ mRenderer = new TerminalRenderer(mRenderer.mTextSize, newTypeface);
+ updateSize();
+ invalidate();
+ }
+
+ @Override
+ public boolean onCheckIsTextEditor() {
+ return true;
+ }
+
+ @Override
+ public boolean isOpaque() {
+ return true;
+ }
+
+ /**
+ * Get the zero indexed column and row of the terminal view for the
+ * position of the event.
+ *
+ * @param event The event with the position to get the column and row for.
+ * @param relativeToScroll If true the column number will take the scroll
+ * position into account. E.g. if scrolled 3 lines up and the event
+ * position is in the top left, column will be -3 if relativeToScroll is
+ * true and 0 if relativeToScroll is false.
+ * @return Array with the column and row.
+ */
+ public int[] getColumnAndRow(MotionEvent event, boolean relativeToScroll) {
+ int column = (int) (event.getX() / mRenderer.mFontWidth);
+ int row = (int) ((event.getY() - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing);
+ if (relativeToScroll) {
+ row += mTopRow;
+ }
+ return new int[] { column, row };
+ }
+
+ /** Send a single mouse event code to the terminal. */
+ void sendMouseEventCode(MotionEvent e, int button, boolean pressed) {
+ int[] columnAndRow = getColumnAndRow(e, false);
+ int x = columnAndRow[0] + 1;
+ int y = columnAndRow[1] + 1;
+ if (pressed && (button == TerminalEmulator.MOUSE_WHEELDOWN_BUTTON || button == TerminalEmulator.MOUSE_WHEELUP_BUTTON)) {
+ if (mMouseStartDownTime == e.getDownTime()) {
+ x = mMouseScrollStartX;
+ y = mMouseScrollStartY;
+ } else {
+ mMouseStartDownTime = e.getDownTime();
+ mMouseScrollStartX = x;
+ mMouseScrollStartY = y;
+ }
+ }
+ mEmulator.sendMouseEvent(button, x, y, pressed);
+ }
+
+ /** Perform a scroll, either from dragging the screen or by scrolling a mouse wheel. */
+ void doScroll(MotionEvent event, int rowsDown) {
+ boolean up = rowsDown < 0;
+ int amount = Math.abs(rowsDown);
+ for (int i = 0; i < amount; i++) {
+ if (mEmulator.isMouseTrackingActive()) {
+ sendMouseEventCode(event, up ? TerminalEmulator.MOUSE_WHEELUP_BUTTON : TerminalEmulator.MOUSE_WHEELDOWN_BUTTON, true);
+ } else if (mEmulator.isAlternateBufferActive()) {
+ // Send up and down key events for scrolling, which is what some terminals do to make scroll work in
+ // e.g. less, which shifts to the alt screen without mouse handling.
+ handleKeyCode(up ? KeyEvent.KEYCODE_DPAD_UP : KeyEvent.KEYCODE_DPAD_DOWN, 0);
+ } else {
+ mTopRow = Math.min(0, Math.max(-(mEmulator.getScreen().getActiveTranscriptRows()), mTopRow + (up ? -1 : 1)));
+ if (!awakenScrollBars()) invalidate();
+ }
+ }
+ }
+
+ /** Overriding {@link View#onGenericMotionEvent(MotionEvent)}. */
+ @Override
+ public boolean onGenericMotionEvent(MotionEvent event) {
+ if (mEmulator != null && event.isFromSource(InputDevice.SOURCE_MOUSE) && event.getAction() == MotionEvent.ACTION_SCROLL) {
+ // Handle mouse wheel scrolling.
+ boolean up = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0.0f;
+ doScroll(event, up ? -3 : 3);
+ return true;
+ }
+ return false;
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ @TargetApi(23)
+ public boolean onTouchEvent(MotionEvent event) {
+ if (mEmulator == null) return true;
+ final int action = event.getAction();
+
+ if (isSelectingText()) {
+ updateFloatingToolbarVisibility(event);
+ mGestureRecognizer.onTouchEvent(event);
+ return true;
+ } else if (event.isFromSource(InputDevice.SOURCE_MOUSE)) {
+ if (event.isButtonPressed(MotionEvent.BUTTON_SECONDARY)) {
+ if (action == MotionEvent.ACTION_DOWN) showContextMenu();
+ return true;
+ } else if (event.isButtonPressed(MotionEvent.BUTTON_TERTIARY)) {
+ ClipboardManager clipboardManager = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ ClipData clipData = clipboardManager.getPrimaryClip();
+ if (clipData != null) {
+ ClipData.Item clipItem = clipData.getItemAt(0);
+ if (clipItem != null) {
+ CharSequence text = clipItem.coerceToText(getContext());
+ if (!TextUtils.isEmpty(text)) mEmulator.paste(text.toString());
+ }
+ }
+ } else if (mEmulator.isMouseTrackingActive()) { // BUTTON_PRIMARY.
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_UP:
+ sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, event.getAction() == MotionEvent.ACTION_DOWN);
+ break;
+ case MotionEvent.ACTION_MOVE:
+ sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true);
+ break;
+ }
+ }
+ }
+
+ mGestureRecognizer.onTouchEvent(event);
+ return true;
+ }
+
+ @Override
+ public boolean onKeyPreIme(int keyCode, KeyEvent event) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient.logInfo(LOG_TAG, "onKeyPreIme(keyCode=" + keyCode + ", event=" + event + ")");
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ cancelRequestAutoFill();
+ if (isSelectingText()) {
+ stopTextSelectionMode();
+ return true;
+ } else if (mClient.shouldBackButtonBeMappedToEscape()) {
+ // Intercept back button to treat it as escape:
+ switch (event.getAction()) {
+ case KeyEvent.ACTION_DOWN:
+ return onKeyDown(keyCode, event);
+ case KeyEvent.ACTION_UP:
+ return onKeyUp(keyCode, event);
+ }
+ }
+ } else if (mClient.shouldUseCtrlSpaceWorkaround() &&
+ keyCode == KeyEvent.KEYCODE_SPACE && event.isCtrlPressed()) {
+ /* ctrl+space does not work on some ROMs without this workaround.
+ However, this breaks it on devices where it works out of the box. */
+ return onKeyDown(keyCode, event);
+ }
+ return super.onKeyPreIme(keyCode, event);
+ }
+
+ /**
+ * Key presses in software keyboards will generally NOT trigger this listener, although some
+ * may elect to do so in some situations. Do not rely on this to catch software key presses.
+ * Gboard calls this when shouldEnforceCharBasedInput() is disabled (InputType.TYPE_NULL) instead
+ * of calling commitText(), with deviceId=-1. However, Hacker's Keyboard, OpenBoard, LG Keyboard
+ * call commitText().
+ *
+ * This function may also be called directly without android calling it, like by
+ * `TerminalExtraKeys` which generates a KeyEvent manually which uses {@link KeyCharacterMap#VIRTUAL_KEYBOARD}
+ * as the device (deviceId=-1), as does Gboard. That would normally use mappings defined in
+ * `/system/usr/keychars/Virtual.kcm`. You can run `dumpsys input` to find the `KeyCharacterMapFile`
+ * used by virtual keyboard or hardware keyboard. Note that virtual keyboard device is not the
+ * same as software keyboard, like Gboard, etc. Its a fake device used for generating events and
+ * for testing.
+ *
+ * We handle shift key in `commitText()` to convert codepoint to uppercase case there with a
+ * call to {@link Character#toUpperCase(int)}, but here we instead rely on getUnicodeChar() for
+ * conversion of keyCode, for both hardware keyboard shift key (via effectiveMetaState) and
+ * `mClient.readShiftKey()`, based on value in kcm files.
+ * This may result in different behaviour depending on keyboard and android kcm files set for the
+ * InputDevice for the event passed to this function. This will likely be an issue for non-english
+ * languages since `Virtual.kcm` in english only by default or at least in AOSP. For both hardware
+ * shift key (via effectiveMetaState) and `mClient.readShiftKey()`, `getUnicodeChar()` is used
+ * for shift specific behaviour which usually is to uppercase.
+ *
+ * For fn key on hardware keyboard, android checks kcm files for hardware keyboards, which is
+ * `Generic.kcm` by default, unless a vendor specific one is defined. The event passed will have
+ * {@link KeyEvent#META_FUNCTION_ON} set. If the kcm file only defines a single character or unicode
+ * code point `\\uxxxx`, then only one event is passed with that value. However, if kcm defines
+ * a `fallback` key for fn or others, like `key DPAD_UP { ... fn: fallback PAGE_UP }`, then
+ * android will first pass an event with original key `DPAD_UP` and {@link KeyEvent#META_FUNCTION_ON}
+ * set. But this function will not consume it and android will pass another event with `PAGE_UP`
+ * and {@link KeyEvent#META_FUNCTION_ON} not set, which will be consumed.
+ *
+ * Now there are some other issues as well, firstly ctrl and alt flags are not passed to
+ * `getUnicodeChar()`, so modified key values in kcm are not used. Secondly, if the kcm file
+ * for other modifiers like shift or fn define a non-alphabet, like { fn: '\u0015' } to act as
+ * DPAD_LEFT, the `getUnicodeChar()` will correctly return `21` as the code point but action will
+ * not happen because the `handleKeyCode()` function that transforms DPAD_LEFT to `\033[D`
+ * escape sequence for the terminal to perform the left action would not be called since its
+ * called before `getUnicodeChar()` and terminal will instead get `21 0x15 Negative Acknowledgement`.
+ * The solution to such issues is calling `getUnicodeChar()` before the call to `handleKeyCode()`
+ * if user has defined a custom kcm file, like done in POC mentioned in #2237. Note that
+ * Hacker's Keyboard calls `commitText()` so don't test fn/shift with it for this function.
+ * https://github.com/termux/termux-app/pull/2237
+ * https://github.com/agnostic-apollo/termux-app/blob/terminal-code-point-custom-mapping/terminal-view/src/main/java/com/termux/view/TerminalView.java
+ *
+ * Key Character Map (kcm) and Key Layout (kl) files info:
+ * https://source.android.com/devices/input/key-character-map-files
+ * https://source.android.com/devices/input/key-layout-files
+ * https://source.android.com/devices/input/keyboard-devices
+ * AOSP kcm and kl files:
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/data/keyboards
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/packages/InputDevices/res/raw
+ *
+ * KeyCodes:
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/view/KeyEvent.java
+ * https://cs.android.com/android/platform/superproject/+/master:frameworks/native/include/android/keycodes.h
+ *
+ * `dumpsys input`:
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/services/inputflinger/reader/EventHub.cpp;l=1917
+ *
+ * Loading of keymap:
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/services/inputflinger/reader/EventHub.cpp;l=1644
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/Keyboard.cpp;l=41
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/InputDevice.cpp
+ * OVERLAY keymaps for hardware keyboards may be combined as well:
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=165
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=831
+ *
+ * Parse kcm file:
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=727
+ * Parse key value:
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=981
+ *
+ * `KeyEvent.getUnicodeChar()`
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/view/KeyEvent.java;l=2716
+ * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/KeyCharacterMap.java;l=368
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/jni/android_view_KeyCharacterMap.cpp;l=117
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=231
+ *
+ * Keyboard layouts advertised by applications, like for hardware keyboards via #ACTION_QUERY_KEYBOARD_LAYOUTS
+ * Config is stored in `/data/system/input-manager-state.xml`
+ * https://github.com/ris58h/custom-keyboard-layout
+ * Loading from apps:
+ * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=1221
+ * Set:
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/hardware/input/InputManager.java;l=89
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/hardware/input/InputManager.java;l=543
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:packages/apps/Settings/src/com/android/settings/inputmethod/KeyboardLayoutDialogFragment.java;l=167
+ * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=1385
+ * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/PersistentDataStore.java
+ * Get overlay keyboard layout
+ * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=2158
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/services/core/jni/com_android_server_input_InputManagerService.cpp;l=616
+ */
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient.logInfo(LOG_TAG, "onKeyDown(keyCode=" + keyCode + ", isSystem()=" + event.isSystem() + ", event=" + event + ")");
+ if (mEmulator == null) return true;
+ if (isSelectingText()) {
+ stopTextSelectionMode();
+ }
+
+ if (mClient.onKeyDown(keyCode, event, mTermSession)) {
+ invalidate();
+ return true;
+ } else if (event.isSystem() && (!mClient.shouldBackButtonBeMappedToEscape() || keyCode != KeyEvent.KEYCODE_BACK)) {
+ return super.onKeyDown(keyCode, event);
+ } else if (event.getAction() == KeyEvent.ACTION_MULTIPLE && keyCode == KeyEvent.KEYCODE_UNKNOWN) {
+ mTermSession.write(event.getCharacters());
+ return true;
+ }
+
+ final int metaState = event.getMetaState();
+ final boolean controlDown = event.isCtrlPressed() || mClient.readControlKey();
+ final boolean leftAltDown = (metaState & KeyEvent.META_ALT_LEFT_ON) != 0 || mClient.readAltKey();
+ final boolean shiftDown = event.isShiftPressed() || mClient.readShiftKey();
+ final boolean rightAltDownFromEvent = (metaState & KeyEvent.META_ALT_RIGHT_ON) != 0;
+
+ int keyMod = 0;
+ if (controlDown) keyMod |= KeyHandler.KEYMOD_CTRL;
+ if (event.isAltPressed() || leftAltDown) keyMod |= KeyHandler.KEYMOD_ALT;
+ if (shiftDown) keyMod |= KeyHandler.KEYMOD_SHIFT;
+ if (event.isNumLockOn()) keyMod |= KeyHandler.KEYMOD_NUM_LOCK;
+ // https://github.com/termux/termux-app/issues/731
+ if (!event.isFunctionPressed() && handleKeyCode(keyCode, keyMod)) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "handleKeyCode() took key event");
+ return true;
+ }
+
+ // Clear Ctrl since we handle that ourselves:
+ int bitsToClear = KeyEvent.META_CTRL_MASK;
+ if (rightAltDownFromEvent) {
+ // Let right Alt/Alt Gr be used to compose characters.
+ } else {
+ // Use left alt to send to terminal (e.g. Left Alt+B to jump back a word), so remove:
+ bitsToClear |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON;
+ }
+ int effectiveMetaState = event.getMetaState() & ~bitsToClear;
+
+ if (shiftDown) effectiveMetaState |= KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_LEFT_ON;
+ if (mClient.readFnKey()) effectiveMetaState |= KeyEvent.META_FUNCTION_ON;
+
+ int result = event.getUnicodeChar(effectiveMetaState);
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient.logInfo(LOG_TAG, "KeyEvent#getUnicodeChar(" + effectiveMetaState + ") returned: " + result);
+ if (result == 0) {
+ return false;
+ }
+
+ int oldCombiningAccent = mCombiningAccent;
+ if ((result & KeyCharacterMap.COMBINING_ACCENT) != 0) {
+ // If entered combining accent previously, write it out:
+ if (mCombiningAccent != 0)
+ inputCodePoint(event.getDeviceId(), mCombiningAccent, controlDown, leftAltDown);
+ mCombiningAccent = result & KeyCharacterMap.COMBINING_ACCENT_MASK;
+ } else {
+ if (mCombiningAccent != 0) {
+ int combinedChar = KeyCharacterMap.getDeadChar(mCombiningAccent, result);
+ if (combinedChar > 0) result = combinedChar;
+ mCombiningAccent = 0;
+ }
+ inputCodePoint(event.getDeviceId(), result, controlDown, leftAltDown);
+ }
+
+ if (mCombiningAccent != oldCombiningAccent) invalidate();
+
+ return true;
+ }
+
+ public void inputCodePoint(int eventSource, int codePoint, boolean controlDownFromEvent, boolean leftAltDownFromEvent) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) {
+ mClient.logInfo(LOG_TAG, "inputCodePoint(eventSource=" + eventSource + ", codePoint=" + codePoint + ", controlDownFromEvent=" + controlDownFromEvent + ", leftAltDownFromEvent="
+ + leftAltDownFromEvent + ")");
+ }
+
+ if (mTermSession == null) return;
+
+ // Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys
+ if (mEmulator != null)
+ mEmulator.setCursorBlinkState(true);
+
+ final boolean controlDown = controlDownFromEvent || mClient.readControlKey();
+ final boolean altDown = leftAltDownFromEvent || mClient.readAltKey();
+
+ if (mClient.onCodePoint(codePoint, controlDown, mTermSession)) return;
+
+ if (controlDown) {
+ if (codePoint >= 'a' && codePoint <= 'z') {
+ codePoint = codePoint - 'a' + 1;
+ } else if (codePoint >= 'A' && codePoint <= 'Z') {
+ codePoint = codePoint - 'A' + 1;
+ } else if (codePoint == ' ' || codePoint == '2') {
+ codePoint = 0;
+ } else if (codePoint == '[' || codePoint == '3') {
+ codePoint = 27; // ^[ (Esc)
+ } else if (codePoint == '\\' || codePoint == '4') {
+ codePoint = 28;
+ } else if (codePoint == ']' || codePoint == '5') {
+ codePoint = 29;
+ } else if (codePoint == '^' || codePoint == '6') {
+ codePoint = 30; // control-^
+ } else if (codePoint == '_' || codePoint == '7' || codePoint == '/') {
+ // "Ctrl-/ sends 0x1f which is equivalent of Ctrl-_ since the days of VT102"
+ // - http://apple.stackexchange.com/questions/24261/how-do-i-send-c-that-is-control-slash-to-the-terminal
+ codePoint = 31;
+ } else if (codePoint == '8') {
+ codePoint = 127; // DEL
+ }
+ }
+
+ if (codePoint > -1) {
+ // If not virtual or soft keyboard.
+ if (eventSource > KEY_EVENT_SOURCE_SOFT_KEYBOARD) {
+ // Work around bluetooth keyboards sending funny unicode characters instead
+ // of the more normal ones from ASCII that terminal programs expect - the
+ // desire to input the original characters should be low.
+ switch (codePoint) {
+ case 0x02DC: // SMALL TILDE.
+ codePoint = 0x007E; // TILDE (~).
+ break;
+ case 0x02CB: // MODIFIER LETTER GRAVE ACCENT.
+ codePoint = 0x0060; // GRAVE ACCENT (`).
+ break;
+ case 0x02C6: // MODIFIER LETTER CIRCUMFLEX ACCENT.
+ codePoint = 0x005E; // CIRCUMFLEX ACCENT (^).
+ break;
+ }
+ }
+
+ // If left alt, send escape before the code point to make e.g. Alt+B and Alt+F work in readline:
+ mTermSession.writeCodePoint(altDown, codePoint);
+ }
+ }
+
+ /** Input the specified keyCode if applicable and return if the input was consumed. */
+ public boolean handleKeyCode(int keyCode, int keyMod) {
+ // Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys
+ if (mEmulator != null)
+ mEmulator.setCursorBlinkState(true);
+
+ if (handleKeyCodeAction(keyCode, keyMod))
+ return true;
+
+ TerminalEmulator term = mTermSession.getEmulator();
+ String code = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode());
+ if (code == null) return false;
+ mTermSession.write(code);
+ return true;
+ }
+
+ public boolean handleKeyCodeAction(int keyCode, int keyMod) {
+ boolean shiftDown = (keyMod & KeyHandler.KEYMOD_SHIFT) != 0;
+
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_PAGE_UP:
+ case KeyEvent.KEYCODE_PAGE_DOWN:
+ // shift+page_up and shift+page_down should scroll scrollback history instead of
+ // scrolling command history or changing pages
+ if (shiftDown) {
+ long time = SystemClock.uptimeMillis();
+ MotionEvent motionEvent = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 0, 0, 0);
+ doScroll(motionEvent, keyCode == KeyEvent.KEYCODE_PAGE_UP ? -mEmulator.mRows : mEmulator.mRows);
+ motionEvent.recycle();
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Called when a key is released in the view.
+ *
+ * @param keyCode The keycode of the key which was released.
+ * @param event A {@link KeyEvent} describing the event.
+ * @return Whether the event was handled.
+ */
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient.logInfo(LOG_TAG, "onKeyUp(keyCode=" + keyCode + ", event=" + event + ")");
+
+ // Do not return for KEYCODE_BACK and send it to the client since user may be trying
+ // to exit the activity.
+ if (mEmulator == null && keyCode != KeyEvent.KEYCODE_BACK) return true;
+
+ if (mClient.onKeyUp(keyCode, event)) {
+ invalidate();
+ return true;
+ } else if (event.isSystem()) {
+ // Let system key events through.
+ return super.onKeyUp(keyCode, event);
+ }
+
+ return true;
+ }
+
+ /**
+ * This is called during layout when the size of this view has changed. If you were just added to the view
+ * hierarchy, you're called with the old values of 0.
+ */
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ updateSize();
+ }
+
+ /** Check if the terminal size in rows and columns should be updated. */
+ public void updateSize() {
+ int viewWidth = getWidth();
+ int viewHeight = getHeight();
+ if (viewWidth == 0 || viewHeight == 0 || mTermSession == null) return;
+
+ // Set to 80 and 24 if you want to enable vttest.
+ int newColumns = Math.max(4, (int) (viewWidth / mRenderer.mFontWidth));
+ int newRows = Math.max(4, (viewHeight - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing);
+
+ if (mEmulator == null || (newColumns != mEmulator.mColumns || newRows != mEmulator.mRows)) {
+ mTermSession.updateSize(newColumns, newRows, (int) mRenderer.getFontWidth(), mRenderer.getFontLineSpacing());
+ mEmulator = mTermSession.getEmulator();
+ mClient.onEmulatorSet();
+
+ // Update mTerminalCursorBlinkerRunnable inner class mEmulator on session change
+ if (mTerminalCursorBlinkerRunnable != null)
+ mTerminalCursorBlinkerRunnable.setEmulator(mEmulator);
+
+ mTopRow = 0;
+ scrollTo(0, 0);
+ invalidate();
+ }
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ if (mEmulator == null) {
+ canvas.drawColor(0XFF000000);
+ } else {
+ // render the terminal view and highlight any selected text
+ int[] sel = mDefaultSelectors;
+ if (mTextSelectionCursorController != null) {
+ mTextSelectionCursorController.getSelectors(sel);
+ }
+
+ mRenderer.render(mEmulator, canvas, mTopRow, sel[0], sel[1], sel[2], sel[3]);
+
+ // render the text selection handles
+ renderTextSelection();
+ }
+ }
+
+ public TerminalSession getCurrentSession() {
+ return mTermSession;
+ }
+
+ private CharSequence getText() {
+ return mEmulator.getScreen().getSelectedText(0, mTopRow, mEmulator.mColumns, mTopRow + mEmulator.mRows);
+ }
+
+ public int getCursorX(float x) {
+ return (int) (x / mRenderer.mFontWidth);
+ }
+
+ public int getCursorY(float y) {
+ return (int) (((y - 40) / mRenderer.mFontLineSpacing) + mTopRow);
+ }
+
+ public int getPointX(int cx) {
+ if (cx > mEmulator.mColumns) {
+ cx = mEmulator.mColumns;
+ }
+ return Math.round(cx * mRenderer.mFontWidth);
+ }
+
+ public int getPointY(int cy) {
+ return Math.round((cy - mTopRow) * mRenderer.mFontLineSpacing);
+ }
+
+ public int getTopRow() {
+ return mTopRow;
+ }
+
+ public void setTopRow(int mTopRow) {
+ this.mTopRow = mTopRow;
+ }
+
+
+
+ /**
+ * Define functions required for AutoFill API
+ */
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ @Override
+ public void autofill(AutofillValue value) {
+ if (value.isText()) {
+ mTermSession.write(value.getTextValue().toString());
+ }
+
+ resetAutoFill();
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ @Override
+ public int getAutofillType() {
+ return mAutoFillType;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ @Override
+ public String[] getAutofillHints() {
+ return mAutoFillHints;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ @Override
+ public AutofillValue getAutofillValue() {
+ return AutofillValue.forText("");
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ @Override
+ public int getImportantForAutofill() {
+ return mAutoFillImportance;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ private synchronized void resetAutoFill() {
+ // Restore none type so that AutoFill UI isn't shown anymore.
+ mAutoFillType = AUTOFILL_TYPE_NONE;
+ mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_NO;
+ mAutoFillHints = new String[0];
+ }
+
+ public AutofillManager getAutoFillManagerService() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null;
+
+ try {
+ Context context = getContext();
+ if (context == null) return null;
+ return context.getSystemService(AutofillManager.class);
+ } catch (Exception e) {
+ mClient.logStackTraceWithMessage(LOG_TAG, "Failed to get AutofillManager service", e);
+ return null;
+ }
+ }
+
+ public boolean isAutoFillEnabled() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false;
+
+ try {
+ AutofillManager autofillManager = getAutoFillManagerService();
+ return autofillManager != null && autofillManager.isEnabled();
+ } catch (Exception e) {
+ mClient.logStackTraceWithMessage(LOG_TAG, "Failed to check if Autofill is enabled", e);
+ return false;
+ }
+ }
+
+ public synchronized void requestAutoFillUsername() {
+ requestAutoFill(
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? new String[]{View.AUTOFILL_HINT_USERNAME} :
+ null);
+ }
+
+ public synchronized void requestAutoFillPassword() {
+ requestAutoFill(
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? new String[]{View.AUTOFILL_HINT_PASSWORD} :
+ null);
+ }
+
+ public synchronized void requestAutoFill(String[] autoFillHints) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
+ if (autoFillHints == null || autoFillHints.length < 1) return;
+
+ try {
+ AutofillManager autofillManager = getAutoFillManagerService();
+ if (autofillManager != null && autofillManager.isEnabled()) {
+ // Update type that will be returned by `getAutofillType()` so that AutoFill UI is shown.
+ mAutoFillType = AUTOFILL_TYPE_TEXT;
+ // Update importance that will be returned by `getImportantForAutofill()` so that
+ // AutoFill considers the view as important.
+ mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_YES;
+ // Update hints that will be returned by `getAutofillHints()` for which to show AutoFill UI.
+ mAutoFillHints = autoFillHints;
+ autofillManager.requestAutofill(this);
+ }
+ } catch (Exception e) {
+ mClient.logStackTraceWithMessage(LOG_TAG, "Failed to request Autofill", e);
+ }
+ }
+
+ public synchronized void cancelRequestAutoFill() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
+ if (mAutoFillType == AUTOFILL_TYPE_NONE) return;
+
+ try {
+ AutofillManager autofillManager = getAutoFillManagerService();
+ if (autofillManager != null && autofillManager.isEnabled()) {
+ resetAutoFill();
+ autofillManager.cancel();
+ }
+ } catch (Exception e) {
+ mClient.logStackTraceWithMessage(LOG_TAG, "Failed to cancel Autofill request", e);
+ }
+ }
+
+
+
+
+
+ /**
+ * Set terminal cursor blinker rate. It must be between {@link #TERMINAL_CURSOR_BLINK_RATE_MIN}
+ * and {@link #TERMINAL_CURSOR_BLINK_RATE_MAX}, otherwise it will be disabled.
+ *
+ * The {@link #setTerminalCursorBlinkerState(boolean, boolean)} must be called after this
+ * for changes to take effect if not disabling.
+ *
+ * @param blinkRate The value to set.
+ * @return Returns {@code true} if setting blinker rate was successfully set, otherwise [@code false}.
+ */
+ public synchronized boolean setTerminalCursorBlinkerRate(int blinkRate) {
+ boolean result;
+
+ // If cursor blinking rate is not valid
+ if (blinkRate != 0 && (blinkRate < TERMINAL_CURSOR_BLINK_RATE_MIN || blinkRate > TERMINAL_CURSOR_BLINK_RATE_MAX)) {
+ mClient.logError(LOG_TAG, "The cursor blink rate must be in between " + TERMINAL_CURSOR_BLINK_RATE_MIN + "-" + TERMINAL_CURSOR_BLINK_RATE_MAX + ": " + blinkRate);
+ mTerminalCursorBlinkerRate = 0;
+ result = false;
+ } else {
+ mClient.logVerbose(LOG_TAG, "Setting cursor blinker rate to " + blinkRate);
+ mTerminalCursorBlinkerRate = blinkRate;
+ result = true;
+ }
+
+ if (mTerminalCursorBlinkerRate == 0) {
+ mClient.logVerbose(LOG_TAG, "Cursor blinker disabled");
+ stopTerminalCursorBlinker();
+ }
+
+ return result;
+ }
+
+ /**
+ * Sets whether cursor blinker should be started or stopped. Cursor blinker will only be
+ * started if {@link #mTerminalCursorBlinkerRate} does not equal 0 and is between
+ * {@link #TERMINAL_CURSOR_BLINK_RATE_MIN} and {@link #TERMINAL_CURSOR_BLINK_RATE_MAX}.
+ *
+ * This should be called when the view holding this activity is resumed or stopped so that
+ * cursor blinker does not run when activity is not visible. If you call this on onResume()
+ * to start cursor blinking, then ensure that {@link #mEmulator} is set, otherwise wait for the
+ * {@link TerminalViewClient#onEmulatorSet()} event after calling {@link #attachSession(TerminalSession)}
+ * for the first session added in the activity since blinking will not start if {@link #mEmulator}
+ * is not set, like if activity is started again after exiting it with double back press. Do not
+ * call this directly after {@link #attachSession(TerminalSession)} since {@link #updateSize()}
+ * may return without setting {@link #mEmulator} since width/height may be 0. Its called again in
+ * {@link #onSizeChanged(int, int, int, int)}. Calling on onResume() if emulator is already set
+ * is necessary, since onEmulatorSet() may not be called after activity is started after device
+ * display timeout with double tap and not power button.
+ *
+ * It should also be called on the
+ * {@link com.termux.terminal.TerminalSessionClient#onTerminalCursorStateChange(boolean)}
+ * callback when cursor is enabled or disabled so that blinker is disabled if cursor is not
+ * to be shown. It should also be checked if activity is visible if blinker is to be started
+ * before calling this.
+ *
+ * It should also be called after terminal is reset with {@link TerminalSession#reset()} in case
+ * cursor blinker was disabled before reset due to call to
+ * {@link com.termux.terminal.TerminalSessionClient#onTerminalCursorStateChange(boolean)}.
+ *
+ * How cursor blinker starting works is by registering a {@link Runnable} with the looper of
+ * the main thread of the app which when run, toggles the cursor blinking state and re-registers
+ * itself to be called with the delay set by {@link #mTerminalCursorBlinkerRate}. When cursor
+ * blinking needs to be disabled, we just cancel any callbacks registered. We don't run our own
+ * "thread" and let the thread for the main looper do the work for us, whose usage is also
+ * required to update the UI, since it also handles other calls to update the UI as well based
+ * on a queue.
+ *
+ * Note that when moving cursor in text editors like nano, the cursor state is quickly
+ * toggled `-> off -> on`, which would call this very quickly sequentially. So that if cursor
+ * is moved 2 or more times quickly, like long hold on arrow keys, it would trigger
+ * `-> off -> on -> off -> on -> ...`, and the "on" callback at index 2 is automatically
+ * cancelled by next "off" callback at index 3 before getting a chance to be run. For this case
+ * we log only if {@link #TERMINAL_VIEW_KEY_LOGGING_ENABLED} is enabled, otherwise would clutter
+ * the log. We don't start the blinking with a delay to immediately show cursor in case it was
+ * previously not visible.
+ *
+ * @param start If cursor blinker should be started or stopped.
+ * @param startOnlyIfCursorEnabled If set to {@code true}, then it will also be checked if the
+ * cursor is even enabled by {@link TerminalEmulator} before
+ * starting the cursor blinker.
+ */
+ public synchronized void setTerminalCursorBlinkerState(boolean start, boolean startOnlyIfCursorEnabled) {
+ // Stop any existing cursor blinker callbacks
+ stopTerminalCursorBlinker();
+
+ if (mEmulator == null) return;
+
+ mEmulator.setCursorBlinkingEnabled(false);
+
+ if (start) {
+ // If cursor blinker is not enabled or is not valid
+ if (mTerminalCursorBlinkerRate < TERMINAL_CURSOR_BLINK_RATE_MIN || mTerminalCursorBlinkerRate > TERMINAL_CURSOR_BLINK_RATE_MAX)
+ return;
+ // If cursor blinder is to be started only if cursor is enabled
+ else if (startOnlyIfCursorEnabled && ! mEmulator.isCursorEnabled()) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient.logVerbose(LOG_TAG, "Ignoring call to start cursor blinker since cursor is not enabled");
+ return;
+ }
+
+ // Start cursor blinker runnable
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient.logVerbose(LOG_TAG, "Starting cursor blinker with the blink rate " + mTerminalCursorBlinkerRate);
+ if (mTerminalCursorBlinkerHandler == null)
+ mTerminalCursorBlinkerHandler = new Handler(Looper.getMainLooper());
+ mTerminalCursorBlinkerRunnable = new TerminalCursorBlinkerRunnable(mEmulator, mTerminalCursorBlinkerRate);
+ mEmulator.setCursorBlinkingEnabled(true);
+ mTerminalCursorBlinkerRunnable.run();
+ }
+ }
+
+ /**
+ * Cancel the terminal cursor blinker callbacks
+ */
+ private void stopTerminalCursorBlinker() {
+ if (mTerminalCursorBlinkerHandler != null && mTerminalCursorBlinkerRunnable != null) {
+ if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
+ mClient.logVerbose(LOG_TAG, "Stopping cursor blinker");
+ mTerminalCursorBlinkerHandler.removeCallbacks(mTerminalCursorBlinkerRunnable);
+ }
+ }
+
+ private class TerminalCursorBlinkerRunnable implements Runnable {
+
+ private TerminalEmulator mEmulator;
+ private final int mBlinkRate;
+
+ // Initialize with false so that initial blink state is visible after toggling
+ boolean mCursorVisible = false;
+
+ public TerminalCursorBlinkerRunnable(TerminalEmulator emulator, int blinkRate) {
+ mEmulator = emulator;
+ mBlinkRate = blinkRate;
+ }
+
+ public void setEmulator(TerminalEmulator emulator) {
+ mEmulator = emulator;
+ }
+
+ public void run() {
+ try {
+ if (mEmulator != null) {
+ // Toggle the blink state and then invalidate() the view so
+ // that onDraw() is called, which then calls TerminalRenderer.render()
+ // which checks with TerminalEmulator.shouldCursorBeVisible() to decide whether
+ // to draw the cursor or not
+ mCursorVisible = !mCursorVisible;
+ //mClient.logVerbose(LOG_TAG, "Toggling cursor blink state to " + mCursorVisible);
+ mEmulator.setCursorBlinkState(mCursorVisible);
+ invalidate();
+ }
+ } finally {
+ // Recall the Runnable after mBlinkRate milliseconds to toggle the blink state
+ mTerminalCursorBlinkerHandler.postDelayed(this, mBlinkRate);
+ }
+ }
+ }
+
+
+
+ /**
+ * Define functions required for text selection and its handles.
+ */
+ TextSelectionCursorController getTextSelectionCursorController() {
+ if (mTextSelectionCursorController == null) {
+ mTextSelectionCursorController = new TextSelectionCursorController(this);
+
+ final ViewTreeObserver observer = getViewTreeObserver();
+ if (observer != null) {
+ observer.addOnTouchModeChangeListener(mTextSelectionCursorController);
+ }
+ }
+
+ return mTextSelectionCursorController;
+ }
+
+ private void showTextSelectionCursors(MotionEvent event) {
+ getTextSelectionCursorController().show(event);
+ }
+
+ private boolean hideTextSelectionCursors() {
+ return getTextSelectionCursorController().hide();
+ }
+
+ private void renderTextSelection() {
+ if (mTextSelectionCursorController != null)
+ mTextSelectionCursorController.render();
+ }
+
+ public boolean isSelectingText() {
+ if (mTextSelectionCursorController != null) {
+ return mTextSelectionCursorController.isActive();
+ } else {
+ return false;
+ }
+ }
+
+ /** Get the currently selected text if selecting. */
+ public String getSelectedText() {
+ if (isSelectingText() && mTextSelectionCursorController != null)
+ return mTextSelectionCursorController.getSelectedText();
+ else
+ return null;
+ }
+
+ /** Get the selected text stored before "MORE" button was pressed on the context menu. */
+ @Nullable
+ public String getStoredSelectedText() {
+ return mTextSelectionCursorController != null ? mTextSelectionCursorController.getStoredSelectedText() : null;
+ }
+
+ /** Unset the selected text stored before "MORE" button was pressed on the context menu. */
+ public void unsetStoredSelectedText() {
+ if (mTextSelectionCursorController != null) mTextSelectionCursorController.unsetStoredSelectedText();
+ }
+
+ private ActionMode getTextSelectionActionMode() {
+ if (mTextSelectionCursorController != null) {
+ return mTextSelectionCursorController.getActionMode();
+ } else {
+ return null;
+ }
+ }
+
+ public void startTextSelectionMode(MotionEvent event) {
+ if (!requestFocus()) {
+ return;
+ }
+
+ showTextSelectionCursors(event);
+ mClient.copyModeChanged(isSelectingText());
+
+ invalidate();
+ }
+
+ public void stopTextSelectionMode() {
+ if (hideTextSelectionCursors()) {
+ mClient.copyModeChanged(isSelectingText());
+ invalidate();
+ }
+ }
+
+ private void decrementYTextSelectionCursors(int decrement) {
+ if (mTextSelectionCursorController != null) {
+ mTextSelectionCursorController.decrementYTextSelectionCursors(decrement);
+ }
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ if (mTextSelectionCursorController != null) {
+ getViewTreeObserver().addOnTouchModeChangeListener(mTextSelectionCursorController);
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (mTextSelectionCursorController != null) {
+ // Might solve the following exception
+ // android.view.WindowLeaked: Activity com.termux.app.TermuxActivity has leaked window android.widget.PopupWindow
+ stopTextSelectionMode();
+
+ getViewTreeObserver().removeOnTouchModeChangeListener(mTextSelectionCursorController);
+ mTextSelectionCursorController.onDetached();
+ }
+ }
+
+
+
+ /**
+ * Define functions required for long hold toolbar.
+ */
+ private final Runnable mShowFloatingToolbar = new Runnable() {
+ @RequiresApi(api = Build.VERSION_CODES.M)
+ @Override
+ public void run() {
+ if (getTextSelectionActionMode() != null) {
+ getTextSelectionActionMode().hide(0); // hide off.
+ }
+ }
+ };
+
+ @RequiresApi(api = Build.VERSION_CODES.M)
+ private void showFloatingToolbar() {
+ if (getTextSelectionActionMode() != null) {
+ int delay = ViewConfiguration.getDoubleTapTimeout();
+ postDelayed(mShowFloatingToolbar, delay);
+ }
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.M)
+ void hideFloatingToolbar() {
+ if (getTextSelectionActionMode() != null) {
+ removeCallbacks(mShowFloatingToolbar);
+ getTextSelectionActionMode().hide(-1);
+ }
+ }
+
+ public void updateFloatingToolbarVisibility(MotionEvent event) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && getTextSelectionActionMode() != null) {
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_MOVE:
+ hideFloatingToolbar();
+ break;
+ case MotionEvent.ACTION_UP: // fall through
+ case MotionEvent.ACTION_CANCEL:
+ showFloatingToolbar();
+ }
+ }
+ }
+
+}
diff --git a/android/terminal-view/src/main/java/com/termux/view/TerminalViewClient.java b/android/terminal-view/src/main/java/com/termux/view/TerminalViewClient.java
new file mode 100644
index 0000000..01a9cdb
--- /dev/null
+++ b/android/terminal-view/src/main/java/com/termux/view/TerminalViewClient.java
@@ -0,0 +1,86 @@
+package com.termux.view;
+
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+import android.view.View;
+
+import com.termux.terminal.TerminalSession;
+
+/**
+ * The interface for communication between {@link TerminalView} and its client. It allows for getting
+ * various configuration options from the client and for sending back data to the client like logs,
+ * key events, both hardware and IME (which makes it different from that available with
+ * {@link View#setOnKeyListener(View.OnKeyListener)}, etc. It must be set for the
+ * {@link TerminalView} through {@link TerminalView#setTerminalViewClient(TerminalViewClient)}.
+ */
+public interface TerminalViewClient {
+
+ /**
+ * Callback function on scale events according to {@link ScaleGestureDetector#getScaleFactor()}.
+ */
+ float onScale(float scale);
+
+
+
+ /**
+ * On a single tap on the terminal if terminal mouse reporting not enabled.
+ */
+ void onSingleTapUp(MotionEvent e);
+
+ boolean shouldBackButtonBeMappedToEscape();
+
+ boolean shouldEnforceCharBasedInput();
+
+ /** Returns the input mode: 0=default, 1=TYPE_NULL, 2=VISIBLE_PASSWORD */
+ int getInputMode();
+
+ boolean shouldUseCtrlSpaceWorkaround();
+
+ boolean isTerminalViewSelected();
+
+
+
+ void copyModeChanged(boolean copyMode);
+
+
+
+ boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession session);
+
+ boolean onKeyUp(int keyCode, KeyEvent e);
+
+ boolean onLongPress(MotionEvent event);
+
+
+
+ boolean readControlKey();
+
+ boolean readAltKey();
+
+ boolean readShiftKey();
+
+ boolean readFnKey();
+
+
+
+ boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session);
+
+
+ void onEmulatorSet();
+
+
+ void logError(String tag, String message);
+
+ void logWarn(String tag, String message);
+
+ void logInfo(String tag, String message);
+
+ void logDebug(String tag, String message);
+
+ void logVerbose(String tag, String message);
+
+ void logStackTraceWithMessage(String tag, String message, Exception e);
+
+ void logStackTrace(String tag, Exception e);
+
+}
diff --git a/android/terminal-view/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.java b/android/terminal-view/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.java
new file mode 100644
index 0000000..24a1797
--- /dev/null
+++ b/android/terminal-view/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+package com.termux.view.support;
+
+import android.util.Log;
+import android.widget.PopupWindow;
+
+import java.lang.reflect.Method;
+
+/**
+ * Implementation of PopupWindow compatibility that can call Gingerbread APIs.
+ * https://chromium.googlesource.com/android_tools/+/HEAD/sdk/extras/android/support/v4/src/gingerbread/android/support/v4/widget/PopupWindowCompatGingerbread.java
+ */
+public class PopupWindowCompatGingerbread {
+
+ private static Method sSetWindowLayoutTypeMethod;
+ private static boolean sSetWindowLayoutTypeMethodAttempted;
+ private static Method sGetWindowLayoutTypeMethod;
+ private static boolean sGetWindowLayoutTypeMethodAttempted;
+
+ public static void setWindowLayoutType(PopupWindow popupWindow, int layoutType) {
+ if (!sSetWindowLayoutTypeMethodAttempted) {
+ try {
+ sSetWindowLayoutTypeMethod = PopupWindow.class.getDeclaredMethod(
+ "setWindowLayoutType", int.class);
+ sSetWindowLayoutTypeMethod.setAccessible(true);
+ } catch (Exception e) {
+ // Reflection method fetch failed. Oh well.
+ }
+ sSetWindowLayoutTypeMethodAttempted = true;
+ }
+ if (sSetWindowLayoutTypeMethod != null) {
+ try {
+ sSetWindowLayoutTypeMethod.invoke(popupWindow, layoutType);
+ } catch (Exception e) {
+ // Reflection call failed. Oh well.
+ }
+ }
+ }
+
+ public static int getWindowLayoutType(PopupWindow popupWindow) {
+ if (!sGetWindowLayoutTypeMethodAttempted) {
+ try {
+ sGetWindowLayoutTypeMethod = PopupWindow.class.getDeclaredMethod(
+ "getWindowLayoutType");
+ sGetWindowLayoutTypeMethod.setAccessible(true);
+ } catch (Exception e) {
+ // Reflection method fetch failed. Oh well.
+ }
+ sGetWindowLayoutTypeMethodAttempted = true;
+ }
+ if (sGetWindowLayoutTypeMethod != null) {
+ try {
+ return (Integer) sGetWindowLayoutTypeMethod.invoke(popupWindow);
+ } catch (Exception e) {
+ // Reflection call failed. Oh well.
+ }
+ }
+ return 0;
+ }
+
+}
diff --git a/android/terminal-view/src/main/java/com/termux/view/textselection/CursorController.java b/android/terminal-view/src/main/java/com/termux/view/textselection/CursorController.java
new file mode 100644
index 0000000..f0e1cc5
--- /dev/null
+++ b/android/terminal-view/src/main/java/com/termux/view/textselection/CursorController.java
@@ -0,0 +1,55 @@
+package com.termux.view.textselection;
+
+import android.view.MotionEvent;
+import android.view.ViewTreeObserver;
+
+import com.termux.view.TerminalView;
+
+/**
+ * A CursorController instance can be used to control cursors in the text.
+ * It is not used outside of {@link TerminalView}.
+ */
+public interface CursorController extends ViewTreeObserver.OnTouchModeChangeListener {
+ /**
+ * Show the cursors on screen. Will be drawn by {@link #render()} by a call during onDraw.
+ * See also {@link #hide()}.
+ */
+ void show(MotionEvent event);
+
+ /**
+ * Hide the cursors from screen.
+ * See also {@link #show(MotionEvent event)}.
+ */
+ boolean hide();
+
+ /**
+ * Render the cursors.
+ */
+ void render();
+
+ /**
+ * Update the cursor positions.
+ */
+ void updatePosition(TextSelectionHandleView handle, int x, int y);
+
+ /**
+ * This method is called by {@link #onTouchEvent(MotionEvent)} and gives the cursors
+ * a chance to become active and/or visible.
+ *
+ * @param event The touch event
+ */
+ boolean onTouchEvent(MotionEvent event);
+
+ /**
+ * Called when the view is detached from window. Perform house keeping task, such as
+ * stopping Runnable thread that would otherwise keep a reference on the context, thus
+ * preventing the activity to be recycled.
+ */
+ void onDetached();
+
+ /**
+ * @return true if the cursors are currently active.
+ */
+ boolean isActive();
+
+}
diff --git a/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java b/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java
new file mode 100644
index 0000000..c2cd7c6
--- /dev/null
+++ b/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java
@@ -0,0 +1,407 @@
+package com.termux.view.textselection;
+
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.graphics.Rect;
+import android.os.Build;
+import android.text.TextUtils;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import com.termux.terminal.TerminalBuffer;
+import com.termux.terminal.WcWidth;
+import com.termux.view.R;
+import com.termux.view.TerminalView;
+
+public class TextSelectionCursorController implements CursorController {
+
+ private final TerminalView terminalView;
+ private final TextSelectionHandleView mStartHandle, mEndHandle;
+ private String mStoredSelectedText;
+ private boolean mIsSelectingText = false;
+ private long mShowStartTime = System.currentTimeMillis();
+
+ private final int mHandleHeight;
+ private int mSelX1 = -1, mSelX2 = -1, mSelY1 = -1, mSelY2 = -1;
+
+ private ActionMode mActionMode;
+ public final int ACTION_COPY = 1;
+ public final int ACTION_PASTE = 2;
+ public final int ACTION_MORE = 3;
+
+ public TextSelectionCursorController(TerminalView terminalView) {
+ this.terminalView = terminalView;
+ mStartHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.LEFT);
+ mEndHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.RIGHT);
+
+ mHandleHeight = Math.max(mStartHandle.getHandleHeight(), mEndHandle.getHandleHeight());
+ }
+
+ @Override
+ public void show(MotionEvent event) {
+ setInitialTextSelectionPosition(event);
+ mStartHandle.positionAtCursor(mSelX1, mSelY1, true);
+ mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, true);
+
+ setActionModeCallBacks();
+ mShowStartTime = System.currentTimeMillis();
+ mIsSelectingText = true;
+ }
+
+ @Override
+ public boolean hide() {
+ if (!isActive()) return false;
+
+ // prevent hide calls right after a show call, like long pressing the down key
+ // 300ms seems long enough that it wouldn't cause hide problems if action button
+ // is quickly clicked after the show, otherwise decrease it
+ if (System.currentTimeMillis() - mShowStartTime < 300) {
+ return false;
+ }
+
+ mStartHandle.hide();
+ mEndHandle.hide();
+
+ if (mActionMode != null) {
+ // This will hide the TextSelectionCursorController
+ mActionMode.finish();
+ }
+
+ mSelX1 = mSelY1 = mSelX2 = mSelY2 = -1;
+ mIsSelectingText = false;
+
+ return true;
+ }
+
+ @Override
+ public void render() {
+ if (!isActive()) return;
+
+ mStartHandle.positionAtCursor(mSelX1, mSelY1, false);
+ mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, false);
+
+ if (mActionMode != null) {
+ mActionMode.invalidate();
+ }
+ }
+
+ public void setInitialTextSelectionPosition(MotionEvent event) {
+ int[] columnAndRow = terminalView.getColumnAndRow(event, true);
+ mSelX1 = mSelX2 = columnAndRow[0];
+ mSelY1 = mSelY2 = columnAndRow[1];
+
+ TerminalBuffer screen = terminalView.mEmulator.getScreen();
+ if (!" ".equals(screen.getSelectedText(mSelX1, mSelY1, mSelX1, mSelY1))) {
+ // Selecting something other than whitespace. Expand to word.
+ while (mSelX1 > 0 && !"".equals(screen.getSelectedText(mSelX1 - 1, mSelY1, mSelX1 - 1, mSelY1))) {
+ mSelX1--;
+ }
+ while (mSelX2 < terminalView.mEmulator.mColumns - 1 && !"".equals(screen.getSelectedText(mSelX2 + 1, mSelY1, mSelX2 + 1, mSelY1))) {
+ mSelX2++;
+ }
+ }
+ }
+
+ public void setActionModeCallBacks() {
+ final ActionMode.Callback callback = new ActionMode.Callback() {
+ @Override
+ public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+ int show = MenuItem.SHOW_AS_ACTION_IF_ROOM | MenuItem.SHOW_AS_ACTION_WITH_TEXT;
+
+ ClipboardManager clipboard = (ClipboardManager) terminalView.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ menu.add(Menu.NONE, ACTION_COPY, Menu.NONE, R.string.copy_text).setShowAsAction(show);
+ menu.add(Menu.NONE, ACTION_PASTE, Menu.NONE, R.string.paste_text).setEnabled(clipboard != null && clipboard.hasPrimaryClip()).setShowAsAction(show);
+ menu.add(Menu.NONE, ACTION_MORE, Menu.NONE, R.string.text_selection_more);
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+ if (!isActive()) {
+ // Fix issue where the dialog is pressed while being dismissed.
+ return true;
+ }
+
+ switch (item.getItemId()) {
+ case ACTION_COPY:
+ String selectedText = getSelectedText();
+ terminalView.mTermSession.onCopyTextToClipboard(selectedText);
+ terminalView.stopTextSelectionMode();
+ break;
+ case ACTION_PASTE:
+ terminalView.stopTextSelectionMode();
+ terminalView.mTermSession.onPasteTextFromClipboard();
+ break;
+ case ACTION_MORE:
+ // We first store the selected text in case TerminalViewClient needs the
+ // selected text before MORE button was pressed since we are going to
+ // stop selection mode
+ mStoredSelectedText = getSelectedText();
+ // The text selection needs to be stopped before showing context menu,
+ // otherwise handles will show above popup
+ terminalView.stopTextSelectionMode();
+ terminalView.showContextMenu();
+ break;
+ }
+
+ return true;
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ }
+
+ };
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ mActionMode = terminalView.startActionMode(callback);
+ return;
+ }
+
+ //noinspection NewApi
+ mActionMode = terminalView.startActionMode(new ActionMode.Callback2() {
+ @Override
+ public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+ return callback.onCreateActionMode(mode, menu);
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+ return callback.onActionItemClicked(mode, item);
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ // Ignore.
+ }
+
+ @Override
+ public void onGetContentRect(ActionMode mode, View view, Rect outRect) {
+ int x1 = Math.round(mSelX1 * terminalView.mRenderer.getFontWidth());
+ int x2 = Math.round(mSelX2 * terminalView.mRenderer.getFontWidth());
+ int y1 = Math.round((mSelY1 - 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing());
+ int y2 = Math.round((mSelY2 + 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing());
+
+ if (x1 > x2) {
+ int tmp = x1;
+ x1 = x2;
+ x2 = tmp;
+ }
+
+ int terminalBottom = terminalView.getBottom();
+ int top = y1 + mHandleHeight;
+ int bottom = y2 + mHandleHeight;
+ if (top > terminalBottom) top = terminalBottom;
+ if (bottom > terminalBottom) bottom = terminalBottom;
+
+ outRect.set(x1, top, x2, bottom);
+ }
+ }, ActionMode.TYPE_FLOATING);
+ }
+
+ @Override
+ public void updatePosition(TextSelectionHandleView handle, int x, int y) {
+ TerminalBuffer screen = terminalView.mEmulator.getScreen();
+ final int scrollRows = screen.getActiveRows() - terminalView.mEmulator.mRows;
+ if (handle == mStartHandle) {
+ mSelX1 = terminalView.getCursorX(x);
+ mSelY1 = terminalView.getCursorY(y);
+ if (mSelX1 < 0) {
+ mSelX1 = 0;
+ }
+
+ if (mSelY1 < -scrollRows) {
+ mSelY1 = -scrollRows;
+
+ } else if (mSelY1 > terminalView.mEmulator.mRows - 1) {
+ mSelY1 = terminalView.mEmulator.mRows - 1;
+
+ }
+
+ if (mSelY1 > mSelY2) {
+ mSelY1 = mSelY2;
+ }
+ if (mSelY1 == mSelY2 && mSelX1 > mSelX2) {
+ mSelX1 = mSelX2;
+ }
+
+ if (!terminalView.mEmulator.isAlternateBufferActive()) {
+ int topRow = terminalView.getTopRow();
+
+ if (mSelY1 <= topRow) {
+ topRow--;
+ if (topRow < -scrollRows) {
+ topRow = -scrollRows;
+ }
+ } else if (mSelY1 >= topRow + terminalView.mEmulator.mRows) {
+ topRow++;
+ if (topRow > 0) {
+ topRow = 0;
+ }
+ }
+
+ terminalView.setTopRow(topRow);
+ }
+
+ mSelX1 = getValidCurX(screen, mSelY1, mSelX1);
+
+ } else {
+ mSelX2 = terminalView.getCursorX(x);
+ mSelY2 = terminalView.getCursorY(y);
+ if (mSelX2 < 0) {
+ mSelX2 = 0;
+ }
+
+ if (mSelY2 < -scrollRows) {
+ mSelY2 = -scrollRows;
+ } else if (mSelY2 > terminalView.mEmulator.mRows - 1) {
+ mSelY2 = terminalView.mEmulator.mRows - 1;
+ }
+
+ if (mSelY1 > mSelY2) {
+ mSelY2 = mSelY1;
+ }
+ if (mSelY1 == mSelY2 && mSelX1 > mSelX2) {
+ mSelX2 = mSelX1;
+ }
+
+ if (!terminalView.mEmulator.isAlternateBufferActive()) {
+ int topRow = terminalView.getTopRow();
+
+ if (mSelY2 <= topRow) {
+ topRow--;
+ if (topRow < -scrollRows) {
+ topRow = -scrollRows;
+ }
+ } else if (mSelY2 >= topRow + terminalView.mEmulator.mRows) {
+ topRow++;
+ if (topRow > 0) {
+ topRow = 0;
+ }
+ }
+
+ terminalView.setTopRow(topRow);
+ }
+
+ mSelX2 = getValidCurX(screen, mSelY2, mSelX2);
+ }
+
+ terminalView.invalidate();
+ }
+
+ private int getValidCurX(TerminalBuffer screen, int cy, int cx) {
+ String line = screen.getSelectedText(0, cy, cx, cy);
+ if (!TextUtils.isEmpty(line)) {
+ int col = 0;
+ for (int i = 0, len = line.length(); i < len; i++) {
+ char ch1 = line.charAt(i);
+ if (ch1 == 0) {
+ break;
+ }
+
+ int wc;
+ if (Character.isHighSurrogate(ch1) && i + 1 < len) {
+ char ch2 = line.charAt(++i);
+ wc = WcWidth.width(Character.toCodePoint(ch1, ch2));
+ } else {
+ wc = WcWidth.width(ch1);
+ }
+
+ final int cend = col + wc;
+ if (cx > col && cx < cend) {
+ return cend;
+ }
+ if (cend == col) {
+ return col;
+ }
+ col = cend;
+ }
+ }
+ return cx;
+ }
+
+ public void decrementYTextSelectionCursors(int decrement) {
+ mSelY1 -= decrement;
+ mSelY2 -= decrement;
+ }
+
+ public boolean onTouchEvent(MotionEvent event) {
+ return false;
+ }
+
+ public void onTouchModeChanged(boolean isInTouchMode) {
+ if (!isInTouchMode) {
+ terminalView.stopTextSelectionMode();
+ }
+ }
+
+ @Override
+ public void onDetached() {
+ }
+
+ @Override
+ public boolean isActive() {
+ return mIsSelectingText;
+ }
+
+ public void getSelectors(int[] sel) {
+ if (sel == null || sel.length != 4) {
+ return;
+ }
+
+ sel[0] = mSelY1;
+ sel[1] = mSelY2;
+ sel[2] = mSelX1;
+ sel[3] = mSelX2;
+ }
+
+ /** Get the currently selected text. */
+ public String getSelectedText() {
+ return terminalView.mEmulator.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2);
+ }
+
+ /** Get the selected text stored before "MORE" button was pressed on the context menu. */
+ @Nullable
+ public String getStoredSelectedText() {
+ return mStoredSelectedText;
+ }
+
+ /** Unset the selected text stored before "MORE" button was pressed on the context menu. */
+ public void unsetStoredSelectedText() {
+ mStoredSelectedText = null;
+ }
+
+ public ActionMode getActionMode() {
+ return mActionMode;
+ }
+
+ /**
+ * @return true if this controller is currently used to move the start selection.
+ */
+ public boolean isSelectionStartDragged() {
+ return mStartHandle.isDragging();
+ }
+
+ /**
+ * @return true if this controller is currently used to move the end selection.
+ */
+ public boolean isSelectionEndDragged() {
+ return mEndHandle.isDragging();
+ }
+
+}
diff --git a/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java b/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java
new file mode 100644
index 0000000..b3caca6
--- /dev/null
+++ b/android/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java
@@ -0,0 +1,352 @@
+package com.termux.view.textselection;
+
+import android.annotation.SuppressLint;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.SystemClock;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.view.WindowManager;
+import android.widget.PopupWindow;
+
+import com.termux.view.R;
+import com.termux.view.TerminalView;
+import com.termux.view.support.PopupWindowCompatGingerbread;
+
+@SuppressLint("ViewConstructor")
+public class TextSelectionHandleView extends View {
+ private final TerminalView terminalView;
+ private PopupWindow mHandle;
+ private final CursorController mCursorController;
+
+ private final Drawable mHandleLeftDrawable;
+ private final Drawable mHandleRightDrawable;
+ private Drawable mHandleDrawable;
+
+ private boolean mIsDragging;
+
+ final int[] mTempCoords = new int[2];
+ Rect mTempRect;
+
+ private int mPointX;
+ private int mPointY;
+ private float mTouchToWindowOffsetX;
+ private float mTouchToWindowOffsetY;
+ private float mHotspotX;
+ private float mHotspotY;
+ private float mTouchOffsetY;
+ private int mLastParentX;
+ private int mLastParentY;
+
+ private int mHandleHeight;
+ private int mHandleWidth;
+
+ private final int mInitialOrientation;
+ private int mOrientation;
+
+ public static final int LEFT = 0;
+ public static final int RIGHT = 2;
+
+ private long mLastTime;
+
+ public TextSelectionHandleView(TerminalView terminalView, CursorController cursorController, int initialOrientation) {
+ super(terminalView.getContext());
+ this.terminalView = terminalView;
+ mCursorController = cursorController;
+ mInitialOrientation = initialOrientation;
+
+ mHandleLeftDrawable = getContext().getDrawable(R.drawable.text_select_handle_left_material);
+ mHandleRightDrawable = getContext().getDrawable(R.drawable.text_select_handle_right_material);
+
+ setOrientation(mInitialOrientation);
+ }
+
+ private void initHandle() {
+ mHandle = new PopupWindow(terminalView.getContext(), null,
+ android.R.attr.textSelectHandleWindowStyle);
+ mHandle.setSplitTouchEnabled(true);
+ mHandle.setClippingEnabled(false);
+ mHandle.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT);
+ mHandle.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
+ mHandle.setBackgroundDrawable(null);
+ mHandle.setAnimationStyle(0);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ mHandle.setWindowLayoutType(WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL);
+ mHandle.setEnterTransition(null);
+ mHandle.setExitTransition(null);
+ } else {
+ PopupWindowCompatGingerbread.setWindowLayoutType(mHandle, WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL);
+ }
+ mHandle.setContentView(this);
+ }
+
+ public void setOrientation(int orientation) {
+ mOrientation = orientation;
+ int handleWidth = 0;
+ switch (orientation) {
+ case LEFT: {
+ mHandleDrawable = mHandleLeftDrawable;
+ handleWidth = mHandleDrawable.getIntrinsicWidth();
+ mHotspotX = (handleWidth * 3) / (float) 4;
+ break;
+ }
+
+ case RIGHT: {
+ mHandleDrawable = mHandleRightDrawable;
+ handleWidth = mHandleDrawable.getIntrinsicWidth();
+ mHotspotX = handleWidth / (float) 4;
+ break;
+ }
+ }
+
+ mHandleHeight = mHandleDrawable.getIntrinsicHeight();
+
+ mHandleWidth = handleWidth;
+ mTouchOffsetY = -mHandleHeight * 0.3f;
+ mHotspotY = 0;
+ invalidate();
+ }
+
+ public void show() {
+ if (!isPositionVisible()) {
+ hide();
+ return;
+ }
+
+ // We remove handle from its parent first otherwise the following exception may be thrown
+ // java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.
+ removeFromParent();
+
+ initHandle(); // init the handle
+ invalidate(); // invalidate to make sure onDraw is called
+
+ final int[] coords = mTempCoords;
+ terminalView.getLocationInWindow(coords);
+ coords[0] += mPointX;
+ coords[1] += mPointY;
+
+ if (mHandle != null)
+ mHandle.showAtLocation(terminalView, 0, coords[0], coords[1]);
+ }
+
+ public void hide() {
+ mIsDragging = false;
+
+ if (mHandle != null) {
+ mHandle.dismiss();
+
+ // We remove handle from its parent, otherwise it may still be shown in some cases even after the dismiss call
+ removeFromParent();
+ mHandle = null; // garbage collect the handle
+ }
+ invalidate();
+ }
+
+ public void removeFromParent() {
+ if (!isParentNull()) {
+ ((ViewGroup)this.getParent()).removeView(this);
+ }
+ }
+
+ public void positionAtCursor(final int cx, final int cy, boolean forceOrientationCheck) {
+ int x = terminalView.getPointX(cx);
+ int y = terminalView.getPointY(cy + 1);
+ moveTo(x, y, forceOrientationCheck);
+ }
+
+ private void moveTo(int x, int y, boolean forceOrientationCheck) {
+ float oldHotspotX = mHotspotX;
+ checkChangedOrientation(x, forceOrientationCheck);
+ mPointX = (int) (x - (isShowing() ? oldHotspotX : mHotspotX));
+ mPointY = y;
+
+ if (isPositionVisible()) {
+ int[] coords = null;
+
+ if (isShowing()) {
+ coords = mTempCoords;
+ terminalView.getLocationInWindow(coords);
+ int x1 = coords[0] + mPointX;
+ int y1 = coords[1] + mPointY;
+ if (mHandle != null)
+ mHandle.update(x1, y1, getWidth(), getHeight());
+ } else {
+ show();
+ }
+
+ if (mIsDragging) {
+ if (coords == null) {
+ coords = mTempCoords;
+ terminalView.getLocationInWindow(coords);
+ }
+ if (coords[0] != mLastParentX || coords[1] != mLastParentY) {
+ mTouchToWindowOffsetX += coords[0] - mLastParentX;
+ mTouchToWindowOffsetY += coords[1] - mLastParentY;
+ mLastParentX = coords[0];
+ mLastParentY = coords[1];
+ }
+ }
+ } else {
+ hide();
+ }
+ }
+
+ public void changeOrientation(int orientation) {
+ if (mOrientation != orientation) {
+ setOrientation(orientation);
+ }
+ }
+
+ private void checkChangedOrientation(int posX, boolean force) {
+ if (!mIsDragging && !force) {
+ return;
+ }
+ long millis = SystemClock.currentThreadTimeMillis();
+ if (millis - mLastTime < 50 && !force) {
+ return;
+ }
+ mLastTime = millis;
+
+ final TerminalView hostView = terminalView;
+ final int left = hostView.getLeft();
+ final int right = hostView.getWidth();
+ final int top = hostView.getTop();
+ final int bottom = hostView.getHeight();
+
+ if (mTempRect == null) {
+ mTempRect = new Rect();
+ }
+ final Rect clip = mTempRect;
+ clip.left = left + terminalView.getPaddingLeft();
+ clip.top = top + terminalView.getPaddingTop();
+ clip.right = right - terminalView.getPaddingRight();
+ clip.bottom = bottom - terminalView.getPaddingBottom();
+
+ final ViewParent parent = hostView.getParent();
+ if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) {
+ return;
+ }
+
+ if (posX - mHandleWidth < clip.left) {
+ changeOrientation(RIGHT);
+ } else if (posX + mHandleWidth > clip.right) {
+ changeOrientation(LEFT);
+ } else {
+ changeOrientation(mInitialOrientation);
+ }
+ }
+
+ private boolean isPositionVisible() {
+ // Always show a dragging handle.
+ if (mIsDragging) {
+ return true;
+ }
+
+ final TerminalView hostView = terminalView;
+ final int left = 0;
+ final int right = hostView.getWidth();
+ final int top = 0;
+ final int bottom = hostView.getHeight();
+
+ if (mTempRect == null) {
+ mTempRect = new Rect();
+ }
+ final Rect clip = mTempRect;
+ clip.left = left + terminalView.getPaddingLeft();
+ clip.top = top + terminalView.getPaddingTop();
+ clip.right = right - terminalView.getPaddingRight();
+ clip.bottom = bottom - terminalView.getPaddingBottom();
+
+ final ViewParent parent = hostView.getParent();
+ if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) {
+ return false;
+ }
+
+ final int[] coords = mTempCoords;
+ hostView.getLocationInWindow(coords);
+ final int posX = coords[0] + mPointX + (int) mHotspotX;
+ final int posY = coords[1] + mPointY + (int) mHotspotY;
+
+ return posX >= clip.left && posX <= clip.right &&
+ posY >= clip.top && posY <= clip.bottom;
+ }
+
+ @Override
+ public void onDraw(Canvas c) {
+ final int width = mHandleDrawable.getIntrinsicWidth();
+ int height = mHandleDrawable.getIntrinsicHeight();
+ mHandleDrawable.setBounds(0, 0, width, height);
+ mHandleDrawable.draw(c);
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ terminalView.updateFloatingToolbarVisibility(event);
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN: {
+ final float rawX = event.getRawX();
+ final float rawY = event.getRawY();
+ mTouchToWindowOffsetX = rawX - mPointX;
+ mTouchToWindowOffsetY = rawY - mPointY;
+ final int[] coords = mTempCoords;
+ terminalView.getLocationInWindow(coords);
+ mLastParentX = coords[0];
+ mLastParentY = coords[1];
+ mIsDragging = true;
+ break;
+ }
+
+ case MotionEvent.ACTION_MOVE: {
+ final float rawX = event.getRawX();
+ final float rawY = event.getRawY();
+
+ final float newPosX = rawX - mTouchToWindowOffsetX + mHotspotX;
+ final float newPosY = rawY - mTouchToWindowOffsetY + mHotspotY + mTouchOffsetY;
+
+ mCursorController.updatePosition(this, Math.round(newPosX), Math.round(newPosY));
+ break;
+ }
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ mIsDragging = false;
+ }
+ return true;
+ }
+
+ @Override
+ public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ setMeasuredDimension(mHandleDrawable.getIntrinsicWidth(),
+ mHandleDrawable.getIntrinsicHeight());
+ }
+
+ public int getHandleHeight() {
+ return mHandleHeight;
+ }
+
+ public int getHandleWidth() {
+ return mHandleWidth;
+ }
+
+ public boolean isShowing() {
+ if (mHandle != null)
+ return mHandle.isShowing();
+ else
+ return false;
+ }
+
+ public boolean isParentNull() {
+ return this.getParent() == null;
+ }
+
+ public boolean isDragging() {
+ return mIsDragging;
+ }
+
+}
diff --git a/android/terminal-view/src/main/res/drawable/text_select_handle_left_material.xml b/android/terminal-view/src/main/res/drawable/text_select_handle_left_material.xml
new file mode 100644
index 0000000..576ff4a
--- /dev/null
+++ b/android/terminal-view/src/main/res/drawable/text_select_handle_left_material.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/terminal-view/src/main/res/drawable/text_select_handle_right_material.xml b/android/terminal-view/src/main/res/drawable/text_select_handle_right_material.xml
new file mode 100644
index 0000000..d049d3a
--- /dev/null
+++ b/android/terminal-view/src/main/res/drawable/text_select_handle_right_material.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/terminal-view/src/main/res/values/strings.xml b/android/terminal-view/src/main/res/values/strings.xml
new file mode 100644
index 0000000..cd03c61
--- /dev/null
+++ b/android/terminal-view/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+ Paste
+ Copy
+ More…
+
diff --git a/android/www/.gitignore b/android/www/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/android/www/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/android/www/eslint.config.js b/android/www/eslint.config.js
new file mode 100644
index 0000000..5e6b472
--- /dev/null
+++ b/android/www/eslint.config.js
@@ -0,0 +1,23 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ },
+])
diff --git a/android/www/index.html b/android/www/index.html
new file mode 100644
index 0000000..b90f5d0
--- /dev/null
+++ b/android/www/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ OpenClaw
+
+
+
+
+
+
+
+
diff --git a/android/www/package-lock.json b/android/www/package-lock.json
new file mode 100644
index 0000000..5cd59ac
--- /dev/null
+++ b/android/www/package-lock.json
@@ -0,0 +1,3286 @@
+{
+ "name": "www-temp",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "www-temp",
+ "version": "1.0.0",
+ "dependencies": {
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.1",
+ "@types/node": "^24.10.1",
+ "@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.1",
+ "eslint": "^9.39.1",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^16.5.0",
+ "typescript": "~5.9.3",
+ "typescript-eslint": "^8.48.0",
+ "vite": "^7.3.1"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
+ "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
+ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.12.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
+ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
+ "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/type-utils": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.56.1",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
+ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
+ "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.56.1",
+ "@typescript-eslint/types": "^8.56.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
+ "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
+ "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz",
+ "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
+ "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
+ "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.56.1",
+ "@typescript-eslint/tsconfig-utils": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
+ "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+ "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz",
+ "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
+ "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.56.1",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
+ "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001777",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
+ "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.307",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
+ "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.3",
+ "@esbuild/android-arm": "0.27.3",
+ "@esbuild/android-arm64": "0.27.3",
+ "@esbuild/android-x64": "0.27.3",
+ "@esbuild/darwin-arm64": "0.27.3",
+ "@esbuild/darwin-x64": "0.27.3",
+ "@esbuild/freebsd-arm64": "0.27.3",
+ "@esbuild/freebsd-x64": "0.27.3",
+ "@esbuild/linux-arm": "0.27.3",
+ "@esbuild/linux-arm64": "0.27.3",
+ "@esbuild/linux-ia32": "0.27.3",
+ "@esbuild/linux-loong64": "0.27.3",
+ "@esbuild/linux-mips64el": "0.27.3",
+ "@esbuild/linux-ppc64": "0.27.3",
+ "@esbuild/linux-riscv64": "0.27.3",
+ "@esbuild/linux-s390x": "0.27.3",
+ "@esbuild/linux-x64": "0.27.3",
+ "@esbuild/netbsd-arm64": "0.27.3",
+ "@esbuild/netbsd-x64": "0.27.3",
+ "@esbuild/openbsd-arm64": "0.27.3",
+ "@esbuild/openbsd-x64": "0.27.3",
+ "@esbuild/openharmony-arm64": "0.27.3",
+ "@esbuild/sunos-x64": "0.27.3",
+ "@esbuild/win32-arm64": "0.27.3",
+ "@esbuild/win32-ia32": "0.27.3",
+ "@esbuild/win32-x64": "0.27.3"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
+ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.5",
+ "@eslint/js": "9.39.4",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
+ "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz",
+ "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": "^9 || ^10"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
+ "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "16.5.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
+ "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.36",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.4"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+ "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz",
+ "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.56.1",
+ "@typescript-eslint/parser": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/android/www/package.json b/android/www/package.json
new file mode 100644
index 0000000..434c0d7
--- /dev/null
+++ b/android/www/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "openclaw-www",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview",
+ "build:zip": "npm run build && cd dist && zip -r ../www.zip . && cd .. && echo 'Created www.zip'"
+ },
+ "dependencies": {
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.1",
+ "@types/node": "^24.10.1",
+ "@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.1",
+ "eslint": "^9.39.1",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^16.5.0",
+ "typescript": "~5.9.3",
+ "typescript-eslint": "^8.48.0",
+ "vite": "^7.3.1"
+ }
+}
diff --git a/android/www/src/App.tsx b/android/www/src/App.tsx
new file mode 100644
index 0000000..f0e3bae
--- /dev/null
+++ b/android/www/src/App.tsx
@@ -0,0 +1,117 @@
+import { useState, useEffect, useCallback } from 'react'
+import { Route, useRoute } from './lib/router'
+import { bridge } from './lib/bridge'
+import { useNativeEvent } from './lib/useNativeEvent'
+import { Setup } from './screens/Setup'
+import { Dashboard } from './screens/Dashboard'
+import { Settings } from './screens/Settings'
+import { SettingsTools } from './screens/SettingsTools'
+import { SettingsKeepAlive } from './screens/SettingsKeepAlive'
+import { SettingsStorage } from './screens/SettingsStorage'
+import { SettingsAbout } from './screens/SettingsAbout'
+import { SettingsUpdates } from './screens/SettingsUpdates'
+import { SettingsPlatforms } from './screens/SettingsPlatforms'
+
+type Tab = 'terminal' | 'dashboard' | 'settings'
+
+export function App() {
+ const { path, navigate } = useRoute()
+ const [hasUpdates, setHasUpdates] = useState(false)
+
+ // Check setup status on mount
+ const [setupDone, setSetupDone] = useState(null)
+
+ useEffect(() => {
+ const status = bridge.callJson<{ bootstrapInstalled?: boolean; platformInstalled?: string }>(
+ 'getSetupStatus'
+ )
+ if (status) {
+ setSetupDone(!!status.bootstrapInstalled && !!status.platformInstalled)
+ } else {
+ // Bridge not available (dev mode) — assume setup done
+ setSetupDone(true)
+ }
+
+ // Check for updates
+ const updates = bridge.callJson('checkForUpdates')
+ if (updates && updates.length > 0) setHasUpdates(true)
+ }, [])
+
+ const onUpdateAvailable = useCallback(() => {
+ setHasUpdates(true)
+ }, [])
+ useNativeEvent('update_available', onUpdateAvailable)
+
+ // Determine active tab from path
+ const activeTab: Tab = path.startsWith('/settings')
+ ? 'settings'
+ : path.startsWith('/setup')
+ ? 'settings'
+ : 'dashboard'
+
+ function handleTabClick(tab: Tab) {
+ if (tab === 'terminal') {
+ bridge.call('showTerminal')
+ return
+ }
+ bridge.call('showWebView')
+ if (tab === 'dashboard') navigate('/dashboard')
+ if (tab === 'settings') navigate('/settings')
+ }
+
+ // Show setup flow if not completed
+ if (setupDone === null) return null // loading
+ if (!setupDone && !path.startsWith('/setup')) {
+ navigate('/setup')
+ }
+
+ return (
+ <>
+ {/* Tab bar */}
+
+ handleTabClick('terminal')}
+ >
+ 🖥 Terminal
+
+ handleTabClick('dashboard')}
+ >
+ 📊 Dashboard
+
+ handleTabClick('settings')}
+ >
+ ⚙ Settings
+ {hasUpdates && }
+
+
+
+ {/* Routes */}
+
+ { setSetupDone(true); navigate('/dashboard') }} />
+
+
+
+
+
+
+
+ >
+ )
+}
+
+function SettingsRouter() {
+ const { path } = useRoute()
+ if (path === '/settings') return
+ if (path === '/settings/tools') return
+ if (path === '/settings/keep-alive') return
+ if (path === '/settings/storage') return
+ if (path === '/settings/about') return
+ if (path === '/settings/updates') return
+ if (path === '/settings/platforms') return
+ return
+}
diff --git a/android/www/src/lib/bridge.ts b/android/www/src/lib/bridge.ts
new file mode 100644
index 0000000..7407aa3
--- /dev/null
+++ b/android/www/src/lib/bridge.ts
@@ -0,0 +1,79 @@
+/**
+ * JsBridge wrapper — typed interface to window.OpenClaw (§2.6).
+ * All Kotlin @JavascriptInterface methods return JSON strings.
+ */
+
+interface OpenClawBridge {
+ showTerminal(): void
+ showWebView(): void
+ createSession(): string
+ switchSession(id: string): void
+ closeSession(id: string): void
+ getTerminalSessions(): string
+ writeToTerminal(id: string, data: string): void
+ getSetupStatus(): string
+ getBootstrapStatus(): string
+ startSetup(): void
+ saveToolSelections(json: string): void
+ getAvailablePlatforms(): string
+ getInstalledPlatforms(): string
+ installPlatform(id: string): void
+ uninstallPlatform(id: string): void
+ switchPlatform(id: string): void
+ getActivePlatform(): string
+ getInstalledTools(): string
+ installTool(id: string): void
+ uninstallTool(id: string): void
+ isToolInstalled(id: string): string
+ runCommand(cmd: string): string
+ runCommandAsync(callbackId: string, cmd: string): void
+ checkForUpdates(): string
+ applyUpdate(component: string): void
+ getAppInfo(): string
+ getBatteryOptimizationStatus(): string
+ requestBatteryOptimizationExclusion(): void
+ openSystemSettings(page: string): void
+ copyToClipboard(text: string): void
+ getStorageInfo(): string
+ clearCache(): void
+}
+
+declare global {
+ interface Window {
+ OpenClaw?: OpenClawBridge
+ __oc?: { emit(type: string, data: unknown): void }
+ }
+}
+
+export function isAvailable(): boolean {
+ return typeof window.OpenClaw !== 'undefined'
+}
+
+export function call(
+ method: K,
+ ...args: Parameters
+): ReturnType | null {
+ if (window.OpenClaw && typeof window.OpenClaw[method] === 'function') {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (window.OpenClaw[method] as (...a: any[]) => any)(...args)
+ }
+ console.warn('[bridge] OpenClaw not available:', method)
+ return null
+}
+
+export function callJson(
+ method: keyof OpenClawBridge,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ...args: any[]
+): T | null {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const raw = (call as any)(method, ...args)
+ if (raw == null) return null
+ try {
+ return JSON.parse(raw as string) as T
+ } catch {
+ return raw as unknown as T
+ }
+}
+
+export const bridge = { isAvailable, call, callJson }
diff --git a/android/www/src/lib/router.tsx b/android/www/src/lib/router.tsx
new file mode 100644
index 0000000..0a6986b
--- /dev/null
+++ b/android/www/src/lib/router.tsx
@@ -0,0 +1,51 @@
+/**
+ * Minimal hash-based router for file:// protocol.
+ * History API doesn't work with file:// — hash routing required.
+ */
+
+import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
+
+interface RouterContext {
+ path: string
+ navigate: (hash: string) => void
+}
+
+const Ctx = createContext({ path: '', navigate: () => {} })
+
+function getHashPath(): string {
+ const hash = window.location.hash
+ return hash ? hash.slice(1) : '/dashboard'
+}
+
+export function Router({ children }: { children: ReactNode }) {
+ const [path, setPath] = useState(getHashPath)
+
+ useEffect(() => {
+ const onChange = () => setPath(getHashPath())
+ window.addEventListener('hashchange', onChange)
+ return () => window.removeEventListener('hashchange', onChange)
+ }, [])
+
+ const navigate = useCallback((hash: string) => {
+ window.location.hash = hash
+ }, [])
+
+ return {children}
+}
+
+export function useRoute(): RouterContext {
+ return useContext(Ctx)
+}
+
+export function Route({ path, children }: { path: string; children: ReactNode }) {
+ const { path: current } = useRoute()
+ // Exact match or prefix match for nested routes
+ if (current === path || current.startsWith(path + '/')) {
+ return <>{children}>
+ }
+ return null
+}
+
+export function navigate(hash: string) {
+ window.location.hash = hash
+}
diff --git a/android/www/src/lib/useNativeEvent.ts b/android/www/src/lib/useNativeEvent.ts
new file mode 100644
index 0000000..6634b18
--- /dev/null
+++ b/android/www/src/lib/useNativeEvent.ts
@@ -0,0 +1,17 @@
+/**
+ * EventBridge hook — listen for Kotlin→WebView events (§2.8).
+ * Kotlin dispatches: window.__oc.emit(type, data)
+ * Which creates: CustomEvent('native:'+type, { detail: data })
+ */
+
+import { useEffect } from 'react'
+
+export function useNativeEvent(type: string, handler: (data: unknown) => void): void {
+ useEffect(() => {
+ const listener = (e: Event) => {
+ handler((e as CustomEvent).detail)
+ }
+ window.addEventListener('native:' + type, listener)
+ return () => window.removeEventListener('native:' + type, listener)
+ }, [type, handler])
+}
diff --git a/android/www/src/main.tsx b/android/www/src/main.tsx
new file mode 100644
index 0000000..eaeb586
--- /dev/null
+++ b/android/www/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { Router } from './lib/router'
+import { App } from './App'
+import './styles/global.css'
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+)
diff --git a/android/www/src/screens/Dashboard.tsx b/android/www/src/screens/Dashboard.tsx
new file mode 100644
index 0000000..2f2eb0b
--- /dev/null
+++ b/android/www/src/screens/Dashboard.tsx
@@ -0,0 +1,214 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useRoute } from '../lib/router'
+import { bridge } from '../lib/bridge'
+import { useNativeEvent } from '../lib/useNativeEvent'
+
+interface BootstrapStatus {
+ installed: boolean
+ prefixPath?: string
+}
+
+interface PlatformInfo {
+ id: string
+ name: string
+}
+
+export function Dashboard() {
+ const { navigate } = useRoute()
+ const [status, setStatus] = useState(null)
+ const [platform, setPlatform] = useState(null)
+ const [gatewayRunning, setGatewayRunning] = useState(false)
+ const gatewayUrl = 'http://localhost:3000'
+ const [runtimeInfo, setRuntimeInfo] = useState>({})
+ const [copied, setCopied] = useState(false)
+
+ function refreshStatus() {
+ const bs = bridge.callJson('getBootstrapStatus')
+ if (bs) setStatus(bs)
+
+ const ap = bridge.callJson('getActivePlatform')
+ if (ap) setPlatform(ap)
+
+ // Check gateway
+ const result = bridge.callJson<{ stdout: string }>('runCommand', 'pgrep -f "openclaw gateway" 2>/dev/null')
+ setGatewayRunning(!!(result?.stdout?.trim()))
+
+ // Get runtime versions
+ const nodeV = bridge.callJson<{ stdout: string }>('runCommand', 'node -v 2>/dev/null')
+ const gitV = bridge.callJson<{ stdout: string }>('runCommand', 'git --version 2>/dev/null')
+ const ocV = bridge.callJson<{ stdout: string }>('runCommand', 'openclaw --version 2>/dev/null')
+ setRuntimeInfo({
+ 'Node.js': nodeV?.stdout?.trim() || '—',
+ 'git': gitV?.stdout?.trim()?.replace('git version ', '') || '—',
+ 'openclaw': ocV?.stdout?.trim() || '—',
+ })
+ }
+
+ useEffect(() => {
+ refreshStatus()
+ const interval = setInterval(refreshStatus, 15000) // Poll every 15s
+ return () => clearInterval(interval)
+ }, [])
+
+ const handleCommandOutput = useCallback(() => {
+ // Refresh after command completes
+ setTimeout(refreshStatus, 2000)
+ }, [])
+ useNativeEvent('command_output', handleCommandOutput)
+
+ function handleCopy() {
+ bridge.call('copyToClipboard', gatewayUrl)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ function handleCheckStatus() {
+ bridge.call('showTerminal')
+ bridge.call('writeToTerminal', '', 'echo "=== OpenClaw Status ==="; echo "Node.js: $(node -v)"; echo "git: $(git --version 2>/dev/null)"; echo "openclaw: $(openclaw --version 2>/dev/null)"; echo "npm: $(npm -v)"; echo "Prefix: $PREFIX"; echo "Arch: $(uname -m)"; df -h $HOME | tail -1; echo "========================"\n')
+ }
+
+ function handleUpdate() {
+ bridge.call('showTerminal')
+ bridge.call('writeToTerminal', '', 'npm install -g openclaw@latest --ignore-scripts && echo "Update complete. Version: $(openclaw --version)"\n')
+ }
+
+ function handleInstallTools() {
+ navigate('/settings/tools')
+ }
+
+ function handleStartGateway() {
+ bridge.call('showTerminal')
+ // Auto-type command
+ bridge.call('writeToTerminal', '', 'openclaw gateway\n')
+ }
+
+ if (!status?.installed) {
+ return (
+
+
+
🧠
+
Setup Required
+
+ The runtime environment hasn't been set up yet.
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Platform header */}
+
+
🧠
+
+
+ {platform?.name || 'OpenClaw'}
+
+
+
+ {gatewayRunning ? 'Running' : 'Not running'}
+
+
+
+
+ {/* Gateway card */}
+ {gatewayRunning ? (
+
+
+ Gateway
+
+
+ {gatewayUrl}
+
+ {copied ? 'Copied!' : 'Copy'}
+
+
+
+ ) : (
+
+
+
+ Gateway is not running. Start it from Terminal:
+
+
+ $ openclaw gateway
+
+
+
+ Open Terminal
+
+
+
+
+ )}
+
+ {/* Quick actions */}
+ {gatewayRunning && (
+
+
+ Quick Actions
+
+
+ bridge.call('runCommandAsync', 'restart', 'pkill -f "openclaw gateway"; sleep 1; openclaw gateway &')}
+ >
+ 🔄 Restart
+
+ bridge.call('runCommandAsync', 'stop', 'pkill -f "openclaw gateway"')}
+ >
+ ⏹ Stop
+
+
+
+ )}
+
+ {/* Runtime info */}
+
Runtime
+
+ {Object.entries(runtimeInfo).map(([key, val]) => (
+
+ {key}
+ {val}
+
+ ))}
+
+
+ {/* Management */}
+
Management
+
+
+
📊
+
+
Status
+
Check versions and environment info
+
+
›
+
+
+
+
+
⬆️
+
+
Update
+
Update OpenClaw to latest version
+
+
›
+
+
+
+
+
🧩
+
+
Install Tools
+
Add or remove optional tools
+
+
›
+
+
+
+ )
+}
diff --git a/android/www/src/screens/Settings.tsx b/android/www/src/screens/Settings.tsx
new file mode 100644
index 0000000..9dbf916
--- /dev/null
+++ b/android/www/src/screens/Settings.tsx
@@ -0,0 +1,41 @@
+import { useRoute } from '../lib/router'
+
+interface MenuItem {
+ icon: string
+ label: string
+ desc: string
+ route: string
+ badge?: boolean
+}
+
+const MENU: MenuItem[] = [
+ { icon: '📱', label: 'Platforms', desc: 'Manage installed platforms', route: '/settings/platforms' },
+ { icon: '🔄', label: 'Updates', desc: 'Check for updates', route: '/settings/updates', badge: false },
+ { icon: '🧰', label: 'Additional Tools', desc: 'Install extra CLI tools', route: '/settings/tools' },
+ { icon: '⚡', label: 'Keep Alive', desc: 'Prevent background killing', route: '/settings/keep-alive' },
+ { icon: '💾', label: 'Storage', desc: 'Manage disk usage', route: '/settings/storage' },
+ { icon: 'ℹ️', label: 'About', desc: 'App info & licenses', route: '/settings/about' },
+]
+
+export function Settings() {
+ const { navigate } = useRoute()
+
+ return (
+
+
Settings
+ {MENU.map(item => (
+
navigate(item.route)}>
+
+
{item.icon}
+
+
{item.label}
+
{item.desc}
+
+ {item.badge &&
}
+
›
+
+
+ ))}
+
+ )
+}
diff --git a/android/www/src/screens/SettingsAbout.tsx b/android/www/src/screens/SettingsAbout.tsx
new file mode 100644
index 0000000..9c5280e
--- /dev/null
+++ b/android/www/src/screens/SettingsAbout.tsx
@@ -0,0 +1,94 @@
+import { useState, useEffect } from 'react'
+import { useRoute } from '../lib/router'
+import { bridge } from '../lib/bridge'
+
+interface AppInfo {
+ versionName: string
+ versionCode: number
+ packageName: string
+}
+
+export function SettingsAbout() {
+ const { navigate } = useRoute()
+ const [appInfo, setAppInfo] = useState(null)
+ const [runtimeInfo, setRuntimeInfo] = useState>({})
+
+ useEffect(() => {
+ const info = bridge.callJson('getAppInfo')
+ if (info) setAppInfo(info)
+
+ // Get runtime versions
+ const nodeV = bridge.callJson<{ stdout: string }>('runCommand', 'node -v 2>/dev/null')
+ const gitV = bridge.callJson<{ stdout: string }>('runCommand', 'git --version 2>/dev/null')
+ setRuntimeInfo({
+ 'Node.js': nodeV?.stdout?.trim() || '—',
+ 'git': gitV?.stdout?.trim()?.replace('git version ', '') || '—',
+ })
+ }, [])
+
+ return (
+
+
+
navigate('/settings')}>←
+
About
+
+
+
+
+
Version
+
+
+ APK
+ {appInfo?.versionName || '—'}
+
+
+ Package
+ {appInfo?.packageName || '—'}
+
+
+
+
Runtime
+
+ {Object.entries(runtimeInfo).map(([key, val]) => (
+
+ {key}
+ {val}
+
+ ))}
+
+
+
+
+
+
+
+ {
+ bridge.call('openSystemSettings', 'app_info')
+ }}
+ >
+ App Info
+
+
+
+
+ Made for Android
+
+
+ )
+}
diff --git a/android/www/src/screens/SettingsKeepAlive.tsx b/android/www/src/screens/SettingsKeepAlive.tsx
new file mode 100644
index 0000000..ce77419
--- /dev/null
+++ b/android/www/src/screens/SettingsKeepAlive.tsx
@@ -0,0 +1,99 @@
+import { useState, useEffect } from 'react'
+import { useRoute } from '../lib/router'
+import { bridge } from '../lib/bridge'
+
+export function SettingsKeepAlive() {
+ const { navigate } = useRoute()
+ const [batteryExcluded, setBatteryExcluded] = useState(false)
+ const [copied, setCopied] = useState(false)
+
+ useEffect(() => {
+ const status = bridge.callJson<{ isIgnoring: boolean }>('getBatteryOptimizationStatus')
+ if (status) setBatteryExcluded(status.isIgnoring)
+ }, [])
+
+ const ppkCommand = 'adb shell device_config set_sync_disabled_for_tests activity_manager/max_phantom_processes 2147483647'
+
+ function handleCopyCommand() {
+ bridge.call('copyToClipboard', ppkCommand)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ function handleRequestExclusion() {
+ bridge.call('requestBatteryOptimizationExclusion')
+ // Re-check after user returns
+ setTimeout(() => {
+ const status = bridge.callJson<{ isIgnoring: boolean }>('getBatteryOptimizationStatus')
+ if (status) setBatteryExcluded(status.isIgnoring)
+ }, 3000)
+ }
+
+ return (
+
+
+
navigate('/settings')}>←
+
Keep Alive
+
+
+
+ Android may kill background processes after a while. Follow these steps to prevent it.
+
+
+ {/* 1. Battery Optimization */}
+
1. Battery Optimization
+
+
+
+ {batteryExcluded ? (
+
✓ Excluded
+ ) : (
+
+ Request Exclusion
+
+ )}
+
+
+
+ {/* 2. Developer Options */}
+
2. Developer Options
+
+
+ • Enable Developer Options
+ • Enable "Stay Awake"
+
+
bridge.call('openSystemSettings', 'developer')}
+ >
+ Open Developer Options
+
+
+
+ {/* 3. Phantom Process Killer */}
+
3. Phantom Process Killer (Android 12+)
+
+
+ Connect USB and enable ADB debugging, then run this command on your PC:
+
+
+ {ppkCommand}
+
+ {copied ? 'Copied!' : 'Copy'}
+
+
+
+
+ {/* 4. Charge Limit */}
+
4. Charge Limit (Optional)
+
+
+ Set battery charge limit to 80% for always-on use. This can be configured in
+ your phone's battery settings.
+
+
+
+ )
+}
diff --git a/android/www/src/screens/SettingsPlatforms.tsx b/android/www/src/screens/SettingsPlatforms.tsx
new file mode 100644
index 0000000..238c171
--- /dev/null
+++ b/android/www/src/screens/SettingsPlatforms.tsx
@@ -0,0 +1,93 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useRoute } from '../lib/router'
+import { bridge } from '../lib/bridge'
+import { useNativeEvent } from '../lib/useNativeEvent'
+
+interface Platform {
+ id: string
+ name: string
+ icon: string
+ desc: string
+}
+
+export function SettingsPlatforms() {
+ const { navigate } = useRoute()
+ const [available, setAvailable] = useState([])
+ const [active, setActive] = useState('')
+ const [installing, setInstalling] = useState(null)
+ const [progress, setProgress] = useState(0)
+
+ useEffect(() => {
+ const data = bridge.callJson('getAvailablePlatforms')
+ if (data) setAvailable(data)
+
+ const ap = bridge.callJson<{ id: string }>('getActivePlatform')
+ if (ap) setActive(ap.id)
+ }, [])
+
+ const onProgress = useCallback((data: unknown) => {
+ const d = data as { target?: string; progress?: number }
+ if (d.progress !== undefined) setProgress(d.progress)
+ if (d.progress !== undefined && d.progress >= 1) {
+ if (d.target) setActive(d.target)
+ setInstalling(null)
+ }
+ }, [])
+ useNativeEvent('install_progress', onProgress)
+
+
+ function handleInstall(id: string) {
+ setInstalling(id)
+ setProgress(0)
+ bridge.call('installPlatform', id)
+ }
+
+ return (
+
+
+
navigate('/settings')}>←
+
Platforms
+
+
+ {installing && (
+
+
Installing {installing}...
+
+
+ )}
+
+ {available.map(p => {
+ const isActive = p.id === active
+ return (
+
+
+
{p.icon}
+
+
+ {p.name}
+ {isActive && (
+
+ Active
+
+ )}
+
+
{p.desc}
+
+ {!isActive && (
+
handleInstall(p.id)}
+ disabled={installing !== null}
+ >
+ Install & Switch
+
+ )}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/android/www/src/screens/SettingsStorage.tsx b/android/www/src/screens/SettingsStorage.tsx
new file mode 100644
index 0000000..eac3a07
--- /dev/null
+++ b/android/www/src/screens/SettingsStorage.tsx
@@ -0,0 +1,124 @@
+import { useState, useEffect } from 'react'
+import { useRoute } from '../lib/router'
+import { bridge } from '../lib/bridge'
+
+interface StorageInfo {
+ totalBytes: number
+ freeBytes: number
+ bootstrapBytes: number
+ wwwBytes: number
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
+}
+
+const STORAGE_COLORS = {
+ bootstrap: '#58a6ff',
+ www: '#3fb950',
+ free: 'var(--bg-tertiary)',
+}
+
+export function SettingsStorage() {
+ const { navigate } = useRoute()
+ const [info, setInfo] = useState(null)
+ const [clearing, setClearing] = useState(false)
+
+ useEffect(() => {
+ const data = bridge.callJson('getStorageInfo')
+ if (data) setInfo(data)
+ }, [])
+
+ function handleClearCache() {
+ setClearing(true)
+ bridge.call('clearCache')
+ setTimeout(() => {
+ setClearing(false)
+ const data = bridge.callJson('getStorageInfo')
+ if (data) setInfo(data)
+ }, 2000)
+ }
+
+ const totalUsed = info ? info.bootstrapBytes + info.wwwBytes : 0
+
+ return (
+
+
+
navigate('/settings')}>←
+
Storage
+
+
+ {info && (
+ <>
+
+ Total used: {formatBytes(totalUsed)}
+
+
+
+
+
+
Bootstrap (usr/)
+
{formatBytes(info.bootstrapBytes)}
+
+
+
+
+
+
+
+
+
Web UI (www/)
+
{formatBytes(info.wwwBytes)}
+
+
+
+
+
+
+
+
+
Free Space
+
{formatBytes(info.freeBytes)}
+
+
+
+
+
+
+ {clearing ? 'Clearing...' : 'Clear Cache'}
+
+
+ >
+ )}
+
+ {!info && (
+
+ Loading storage info...
+
+ )}
+
+ )
+}
diff --git a/android/www/src/screens/SettingsTools.tsx b/android/www/src/screens/SettingsTools.tsx
new file mode 100644
index 0000000..5e9c219
--- /dev/null
+++ b/android/www/src/screens/SettingsTools.tsx
@@ -0,0 +1,126 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useRoute } from '../lib/router'
+import { bridge } from '../lib/bridge'
+import { useNativeEvent } from '../lib/useNativeEvent'
+
+interface Tool {
+ id: string
+ name: string
+ desc: string
+ category: string
+}
+
+const TOOLS: Tool[] = [
+ { id: 'tmux', name: 'tmux', desc: 'Terminal multiplexer', category: 'Terminal Tools' },
+ { id: 'code-server', name: 'code-server', desc: 'VS Code in browser', category: 'Terminal Tools' },
+ { id: 'opencode', name: 'OpenCode', desc: 'AI coding assistant (TUI)', category: 'AI Tools' },
+ { id: 'claude-code', name: 'Claude Code', desc: 'Anthropic AI CLI', category: 'AI Tools' },
+ { id: 'gemini-cli', name: 'Gemini CLI', desc: 'Google AI CLI', category: 'AI Tools' },
+ { id: 'codex-cli', name: 'Codex CLI', desc: 'OpenAI AI CLI', category: 'AI Tools' },
+ { id: 'openssh-server', name: 'SSH Server', desc: 'SSH remote access', category: 'Network & Access' },
+ { id: 'ttyd', name: 'ttyd', desc: 'Web terminal access', category: 'Network & Access' },
+ { id: 'dufs', name: 'dufs', desc: 'File server (WebDAV)', category: 'Network & Access' },
+ { id: 'android-tools', name: 'Android Tools', desc: 'ADB for disabling Phantom Process Killer', category: 'System' },
+ { id: 'chromium', name: 'Chromium', desc: 'Browser automation (~400MB)', category: 'System' },
+]
+
+export function SettingsTools() {
+ const { navigate } = useRoute()
+ const [installed, setInstalled] = useState>(new Set())
+ const [installing, setInstalling] = useState(null)
+ const [progress, setProgress] = useState(0)
+ const [progressMsg, setProgressMsg] = useState('')
+
+ useEffect(() => {
+ // Check installed status for each tool
+ const result = bridge.callJson>('getInstalledTools')
+ if (result) {
+ setInstalled(new Set(result.map(t => t.id)))
+ }
+ }, [])
+
+ const onInstallProgress = useCallback((data: unknown) => {
+ const d = data as { target?: string; progress?: number; message?: string }
+ if (d.progress !== undefined) setProgress(d.progress)
+ if (d.message) setProgressMsg(d.message)
+ if (d.progress !== undefined && d.progress >= 1) {
+ if (d.target) setInstalled(prev => new Set([...prev, d.target!]))
+ setInstalling(null)
+ setProgress(0)
+ }
+ }, [])
+ useNativeEvent('install_progress', onInstallProgress)
+
+ function handleInstall(id: string) {
+ setInstalling(id)
+ setProgress(0)
+ setProgressMsg(`Installing ${id}...`)
+ bridge.call('installTool', id)
+ }
+
+ function handleUninstall(id: string) {
+ bridge.call('uninstallTool', id)
+ setInstalled(prev => {
+ const next = new Set(prev)
+ next.delete(id)
+ return next
+ })
+ }
+
+ // Group by category
+ const categories = [...new Set(TOOLS.map(t => t.category))]
+
+ return (
+
+
+
navigate('/settings')}>←
+
Additional Tools
+
+
+ {installing && (
+
+
Installing {installing}...
+
+
+ {progressMsg}
+
+
+ )}
+
+ {categories.map(cat => (
+
+
{cat}
+ {TOOLS.filter(t => t.category === cat).map(tool => (
+
+
+
+
{tool.name}
+
{tool.desc}
+
+ {installed.has(tool.id) ? (
+
handleUninstall(tool.id)}
+ disabled={installing !== null}
+ >
+ Installed ✓
+
+ ) : (
+
handleInstall(tool.id)}
+ disabled={installing !== null}
+ >
+ Install
+
+ )}
+
+
+ ))}
+
+ ))}
+
+ )
+}
diff --git a/android/www/src/screens/SettingsUpdates.tsx b/android/www/src/screens/SettingsUpdates.tsx
new file mode 100644
index 0000000..a69ad24
--- /dev/null
+++ b/android/www/src/screens/SettingsUpdates.tsx
@@ -0,0 +1,90 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useRoute } from '../lib/router'
+import { bridge } from '../lib/bridge'
+import { useNativeEvent } from '../lib/useNativeEvent'
+
+interface UpdateItem {
+ component: string
+ currentVersion: string
+ newVersion: string
+}
+
+export function SettingsUpdates() {
+ const { navigate } = useRoute()
+ const [updates, setUpdates] = useState([])
+ const [updating, setUpdating] = useState(null)
+ const [progress, setProgress] = useState(0)
+ const [checking, setChecking] = useState(true)
+
+ useEffect(() => {
+ const data = bridge.callJson('checkForUpdates')
+ setUpdates(data || [])
+ setChecking(false)
+ }, [])
+
+ const onProgress = useCallback((data: unknown) => {
+ const d = data as { target?: string; progress?: number }
+ if (d.progress !== undefined) setProgress(d.progress)
+ if (d.progress !== undefined && d.progress >= 1) {
+ setUpdating(null)
+ setUpdates(prev => prev.filter(u => u.component !== d.target))
+ }
+ }, [])
+ useNativeEvent('install_progress', onProgress)
+
+ function handleApply(component: string) {
+ setUpdating(component)
+ setProgress(0)
+ bridge.call('applyUpdate', component)
+ }
+
+ return (
+
+
+
navigate('/settings')}>←
+
Updates
+
+
+ {checking && (
+
+ Checking for updates...
+
+ )}
+
+ {!checking && updates.length === 0 && (
+
+ Everything is up to date.
+
+ )}
+
+ {updating && (
+
+
Updating {updating}...
+
+
+ )}
+
+ {updates.map(u => (
+
+
+
+
{u.component}
+
+ {u.currentVersion} → {u.newVersion}
+
+
+
handleApply(u.component)}
+ disabled={updating !== null}
+ >
+ Update
+
+
+
+ ))}
+
+ )
+}
diff --git a/android/www/src/screens/Setup.tsx b/android/www/src/screens/Setup.tsx
new file mode 100644
index 0000000..4e1c3c7
--- /dev/null
+++ b/android/www/src/screens/Setup.tsx
@@ -0,0 +1,251 @@
+import { useState, useCallback, useEffect, Fragment } from 'react'
+import { bridge } from '../lib/bridge'
+import { useNativeEvent } from '../lib/useNativeEvent'
+
+interface Props {
+ onComplete: () => void
+}
+
+type SetupPhase = 'platform-select' | 'tool-select' | 'installing' | 'done'
+
+interface Platform {
+ id: string
+ name: string
+ icon: string
+ desc: string
+}
+
+const OPTIONAL_TOOLS = [
+ { id: 'tmux', name: 'tmux', desc: 'Terminal multiplexer for background sessions' },
+ { id: 'ttyd', name: 'ttyd', desc: 'Web terminal — access from a browser' },
+ { id: 'dufs', name: 'dufs', desc: 'File server (WebDAV)' },
+ { id: 'code-server', name: 'code-server', desc: 'VS Code in browser' },
+ { id: 'claude-code', name: 'Claude Code', desc: 'Anthropic AI CLI' },
+ { id: 'gemini-cli', name: 'Gemini CLI', desc: 'Google AI CLI' },
+ { id: 'codex-cli', name: 'Codex CLI', desc: 'OpenAI AI CLI' },
+]
+
+const TIPS = [
+ 'You can install multiple AI platforms and switch between them anytime.',
+ 'Setup is a one-time process. Future launches are instant.',
+ 'Once setup is complete, your AI assistant runs at full speed — just like on a computer.',
+ 'All processing happens locally on your device. Your data never leaves your phone.',
+]
+
+export function Setup({ onComplete }: Props) {
+ const [phase, setPhase] = useState('platform-select')
+ const [platforms, setPlatforms] = useState([])
+ const [selectedPlatform, setSelectedPlatform] = useState('')
+ const [selectedTools, setSelectedTools] = useState>(new Set())
+ const [progress, setProgress] = useState(0)
+ const [message, setMessage] = useState('')
+ const [error, setError] = useState('')
+ const [tipIndex, setTipIndex] = useState(0)
+
+ // Load available platforms
+ useEffect(() => {
+ const data = bridge.callJson('getAvailablePlatforms')
+ if (data) {
+ setPlatforms(data)
+ } else {
+ setPlatforms([
+ { id: 'openclaw', name: 'OpenClaw', icon: '🧠', desc: 'AI agent platform' },
+ ])
+ }
+ }, [])
+
+ const onProgress = useCallback((data: unknown) => {
+ const d = data as { progress?: number; message?: string }
+ if (d.progress !== undefined) setProgress(d.progress)
+ if (d.message) setMessage(d.message)
+ if (d.progress !== undefined && d.progress >= 1) {
+ setPhase('done')
+ }
+ setTipIndex(i => (i + 1) % TIPS.length)
+ }, [])
+
+ useNativeEvent('setup_progress', onProgress)
+
+ function handleSelectPlatform(id: string) {
+ setSelectedPlatform(id)
+ setPhase('tool-select')
+ }
+
+ function toggleTool(id: string) {
+ setSelectedTools(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ function handleStartSetup() {
+ // Save tool selections
+ const selections: Record = {}
+ OPTIONAL_TOOLS.forEach(t => {
+ selections[t.id] = selectedTools.has(t.id)
+ })
+ bridge.call('saveToolSelections', JSON.stringify(selections))
+
+ // Start bootstrap setup
+ setPhase('installing')
+ setProgress(0)
+ setMessage('Preparing setup...')
+ setError('')
+ bridge.call('startSetup')
+ }
+
+ // --- Stepper ---
+ const currentStep = phase === 'platform-select' ? 0
+ : phase === 'tool-select' ? 1
+ : phase === 'installing' ? 2 : 3
+
+ const STEPS = ['Platform', 'Tools', 'Setup']
+
+ function renderStepper() {
+ return (
+
+ {STEPS.map((label, i) => (
+
+ {i > 0 &&
}
+
+ {i < currentStep ? '✓' : i === currentStep ? '●' : '○'}
+ {label}
+
+
+ ))}
+
+ )
+ }
+
+ // --- Platform Select ---
+ if (phase === 'platform-select') {
+ return (
+
+ {renderStepper()}
+
Choose your platform
+
+ {platforms.map(p => (
+
handleSelectPlatform(p.id)}
+ >
+
{p.icon}
+
{p.name}
+
+ {p.desc}
+
+
+ ))}
+
+
More platforms available in Settings.
+
+ )
+ }
+
+ // --- Tool Select ---
+ if (phase === 'tool-select') {
+ return (
+
+ {renderStepper()}
+
+
Optional Tools
+
+ Select tools to install alongside {selectedPlatform}. You can always add more later in Settings.
+
+
+
+ {OPTIONAL_TOOLS.map(tool => {
+ const isSelected = selectedTools.has(tool.id)
+ return (
+
toggleTool(tool.id)}
+ >
+
+
+
{tool.name}
+
{tool.desc}
+
+
+
+
+ )
+ })}
+
+
+
+ Start Setup
+
+
+ )
+ }
+
+ // --- Installing ---
+ if (phase === 'installing') {
+ const pct = Math.round(progress * 100)
+ return (
+
+ {renderStepper()}
+
Setting up...
+
+
+
+
+ {pct}%
+
+
+ {message}
+
+
+
+ {error && (
+
{error}
+ )}
+
+
💡 {TIPS[tipIndex]}
+
+ )
+ }
+
+ // --- Done ---
+ return (
+
+ {renderStepper()}
+
✅
+
You're all set!
+
+ The terminal will now install runtime components and your selected tools. This takes 3–10 minutes.
+
+
+
{
+ bridge.call('showTerminal')
+ onComplete()
+ }}>
+ Open Terminal
+
+
+ )
+}
diff --git a/android/www/src/styles/global.css b/android/www/src/styles/global.css
new file mode 100644
index 0000000..a84ee5a
--- /dev/null
+++ b/android/www/src/styles/global.css
@@ -0,0 +1,438 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root {
+ --bg-primary: #0d1117;
+ --bg-secondary: #161b22;
+ --bg-tertiary: #21262d;
+ --text-primary: #f0f6fc;
+ --text-secondary: #8b949e;
+ --accent: #58a6ff;
+ --accent-hover: #79c0ff;
+ --success: #3fb950;
+ --warning: #d29922;
+ --error: #f85149;
+ --border: #30363d;
+ --radius: 8px;
+}
+
+html, body, #root {
+ height: 100%;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ overflow-x: hidden;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+/* --- Tab bar --- */
+.tab-bar {
+ display: flex;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 48px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ z-index: 100;
+}
+
+.tab-bar-item {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: color 0.15s;
+ border: none;
+ background: none;
+ position: relative;
+ min-height: 48px;
+}
+
+.tab-bar-item.active {
+ color: var(--accent);
+}
+
+.tab-bar-item.active::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 20%;
+ right: 20%;
+ height: 2px;
+ background: var(--accent);
+ border-radius: 1px;
+}
+
+.tab-bar-item .badge {
+ position: absolute;
+ top: 10px;
+ right: calc(50% - 30px);
+ width: 8px;
+ height: 8px;
+ background: var(--error);
+ border-radius: 50%;
+}
+
+/* --- Page container --- */
+.page {
+ padding: 64px 16px 24px;
+ min-height: 100vh;
+}
+
+.page-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.page-header .back-btn {
+ background: none;
+ border: none;
+ color: var(--accent);
+ font-size: 18px;
+ cursor: pointer;
+ padding: 8px;
+ margin: -8px;
+ min-width: 44px;
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.page-title {
+ font-size: 22px;
+ font-weight: 700;
+}
+
+/* --- Cards --- */
+.card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 16px;
+ margin-bottom: 12px;
+}
+
+.card-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+ min-height: 48px;
+}
+
+.card-row .card-icon {
+ font-size: 20px;
+ width: 32px;
+ flex-shrink: 0;
+}
+
+.card-row .card-content {
+ flex: 1;
+ margin-left: 4px;
+}
+
+.card-row .card-label {
+ font-size: 15px;
+ font-weight: 500;
+}
+
+.card-row .card-desc {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-top: 2px;
+}
+
+.card-row .card-chevron {
+ color: var(--text-secondary);
+ font-size: 16px;
+ margin-left: 8px;
+}
+
+.card-row .card-badge {
+ width: 8px;
+ height: 8px;
+ background: var(--error);
+ border-radius: 50%;
+ margin-right: 4px;
+}
+
+/* --- Buttons --- */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 12px 24px;
+ font-size: 15px;
+ font-weight: 600;
+ border: none;
+ border-radius: var(--radius);
+ cursor: pointer;
+ transition: all 0.15s;
+ min-width: 120px;
+ min-height: 48px;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: #fff;
+}
+
+.btn-primary:active {
+ background: var(--accent-hover);
+ transform: scale(0.98);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+}
+
+.btn-secondary:active {
+ background: var(--border);
+}
+
+.btn-small {
+ padding: 6px 16px;
+ font-size: 13px;
+ min-width: 80px;
+ min-height: 36px;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* --- Progress bar --- */
+.progress-bar {
+ height: 6px;
+ background: var(--bg-tertiary);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.progress-fill {
+ height: 100%;
+ background: var(--accent);
+ border-radius: 3px;
+ transition: width 0.3s ease;
+}
+
+/* --- Status dots --- */
+.status-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: 8px;
+}
+
+.status-dot.success { background: var(--success); }
+.status-dot.warning { background: var(--warning); }
+.status-dot.error { background: var(--error); }
+.status-dot.pending { background: var(--text-secondary); }
+
+/* --- Stepper --- */
+.stepper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ padding: 16px 0;
+}
+
+.step {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.step.done { color: var(--success); }
+.step.active { color: var(--accent); }
+
+.step-icon {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ border: 2px solid var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.step.done .step-icon {
+ background: var(--success);
+ border-color: var(--success);
+ color: #fff;
+}
+
+.step.active .step-icon {
+ border-color: var(--accent);
+ color: var(--accent);
+ animation: pulse 1.5s infinite;
+}
+
+.step-line {
+ width: 24px;
+ height: 2px;
+ background: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.step.done + .step-line,
+.step-line.done {
+ background: var(--success);
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+/* --- Code block --- */
+.code-block {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 12px 16px;
+ font-family: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
+ font-size: 13px;
+ color: var(--text-primary);
+ position: relative;
+ -webkit-user-select: text;
+ user-select: text;
+ word-break: break-all;
+}
+
+.code-block .copy-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ color: var(--text-secondary);
+ font-size: 12px;
+ padding: 4px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.code-block .copy-btn:active {
+ color: var(--accent);
+}
+
+/* --- Section --- */
+.section-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin: 24px 0 12px;
+}
+
+/* --- Info row --- */
+.info-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 10px 0;
+ font-size: 14px;
+ border-bottom: 1px solid var(--border);
+}
+
+.info-row:last-child {
+ border-bottom: none;
+}
+
+.info-row .label {
+ color: var(--text-secondary);
+}
+
+/* --- Setup centered container --- */
+.setup-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 24px;
+ gap: 20px;
+}
+
+.setup-logo {
+ font-size: 64px;
+ line-height: 1;
+}
+
+.setup-title {
+ font-size: 28px;
+ font-weight: 700;
+ text-align: center;
+}
+
+.setup-subtitle {
+ font-size: 14px;
+ color: var(--text-secondary);
+ text-align: center;
+ max-width: 300px;
+ line-height: 1.5;
+}
+
+/* --- Tip card --- */
+.tip-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 14px 16px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ max-width: 320px;
+ text-align: center;
+ line-height: 1.5;
+}
+
+/* --- Storage bar --- */
+.storage-bar {
+ height: 8px;
+ background: var(--bg-tertiary);
+ border-radius: 4px;
+ overflow: hidden;
+ margin-top: 8px;
+}
+
+.storage-fill {
+ height: 100%;
+ border-radius: 4px;
+ transition: width 0.3s ease;
+}
+
+/* --- Divider --- */
+.divider {
+ height: 1px;
+ background: var(--border);
+ margin: 16px 0;
+}
diff --git a/android/www/src/vite-env.d.ts b/android/www/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/android/www/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/android/www/tsconfig.app.json b/android/www/tsconfig.app.json
new file mode 100644
index 0000000..a9b5a59
--- /dev/null
+++ b/android/www/tsconfig.app.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/android/www/tsconfig.json b/android/www/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/android/www/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/android/www/tsconfig.node.json b/android/www/tsconfig.node.json
new file mode 100644
index 0000000..8a67f62
--- /dev/null
+++ b/android/www/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/android/www/vite.config.ts b/android/www/vite.config.ts
new file mode 100644
index 0000000..5cf836f
--- /dev/null
+++ b/android/www/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ base: './',
+ build: {
+ outDir: 'dist',
+ assetsDir: 'assets',
+ sourcemap: false,
+ minify: 'esbuild',
+ },
+})
diff --git a/android/www/www.zip b/android/www/www.zip
new file mode 100644
index 0000000..4b80029
Binary files /dev/null and b/android/www/www.zip differ
diff --git a/bootstrap.sh b/bootstrap.sh
index ae17096..67dd227 100755
--- a/bootstrap.sh
+++ b/bootstrap.sh
@@ -3,11 +3,10 @@
# Usage: curl -sL https://raw.githubusercontent.com/AidanPark/openclaw-android/main/bootstrap.sh | bash
set -euo pipefail
-REPO_BASE="https://raw.githubusercontent.com/AidanPark/openclaw-android/main"
+REPO_TARBALL="https://github.com/AidanPark/openclaw-android/archive/refs/heads/main.tar.gz"
INSTALL_DIR="$HOME/.openclaw-android/installer"
RED='\033[0;31m'
-GREEN='\033[0;32m'
BOLD='\033[1m'
NC='\033[0m'
@@ -15,68 +14,17 @@ echo ""
echo -e "${BOLD}OpenClaw on Android - Bootstrap${NC}"
echo ""
-# Ensure curl is available
if ! command -v curl &>/dev/null; then
echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl"
exit 1
fi
-# Create installer directory structure
-mkdir -p "$INSTALL_DIR"/{patches,scripts,tests}
+echo "Downloading installer..."
+mkdir -p "$INSTALL_DIR"
+curl -sfL "$REPO_TARBALL" | tar xz -C "$INSTALL_DIR" --strip-components=1
-# File list to download
-FILES=(
- "install.sh"
- "uninstall.sh"
- "patches/bionic-compat.js"
- "patches/patch-paths.sh"
- "patches/apply-patches.sh"
- "patches/spawn.h"
- "patches/termux-compat.h"
- "patches/systemctl"
- "scripts/check-env.sh"
- "scripts/install-deps.sh"
- "scripts/setup-paths.sh"
- "scripts/setup-env.sh"
- "scripts/build-sharp.sh"
- "tests/verify-install.sh"
- "update.sh"
-)
-
-# Download all files
-echo "Downloading installer files..."
-FAILED=0
-for f in "${FILES[@]}"; do
- if curl -sfL "$REPO_BASE/$f" -o "$INSTALL_DIR/$f"; then
- echo -e " ${GREEN}[OK]${NC} $f"
- else
- echo -e " ${RED}[FAIL]${NC} $f"
- FAILED=$((FAILED + 1))
- fi
-done
-
-if [ "$FAILED" -gt 0 ]; then
- echo ""
- echo -e "${RED}Failed to download $FAILED file(s). Check your internet connection.${NC}"
- rm -rf "$INSTALL_DIR"
- exit 1
-fi
-
-# Make scripts executable
-chmod +x "$INSTALL_DIR"/*.sh "$INSTALL_DIR"/patches/*.sh "$INSTALL_DIR"/scripts/*.sh "$INSTALL_DIR"/tests/*.sh
-
-echo ""
-echo "Running installer..."
-echo ""
-
-# Run installer
bash "$INSTALL_DIR/install.sh"
-# Keep uninstall.sh accessible, clean up the rest
-# (oaupdate command is already installed by install.sh)
cp "$INSTALL_DIR/uninstall.sh" "$HOME/.openclaw-android/uninstall.sh"
chmod +x "$HOME/.openclaw-android/uninstall.sh"
rm -rf "$INSTALL_DIR"
-
-echo "Uninstaller saved at: ~/.openclaw-android/uninstall.sh"
-echo "To update later: oaupdate && source ~/.bashrc"
diff --git a/docs/disable-phantom-process-killer.ko.md b/docs/disable-phantom-process-killer.ko.md
new file mode 100644
index 0000000..7917eb9
--- /dev/null
+++ b/docs/disable-phantom-process-killer.ko.md
@@ -0,0 +1,163 @@
+# Android에서 프로세스 라이브 상태 유지
+
+OpenClaw는 서버로 동작하므로 Android의 전원 관리 및 프로세스 종료 기능이 안정적인 운영을 방해할 수 있습니다. 이 가이드에서는 프로세스를 안정적으로 유지하기 위한 모든 설정을 다룹니다.
+
+## 개발자 옵션 활성화
+
+1. **설정** > **휴대전화 정보** (또는 **디바이스 정보**)
+2. **빌드 번호**를 7번 연속 탭
+3. "개발자 모드가 활성화되었습니다" 메시지 확인
+4. 잠금화면 비밀번호가 설정되어 있으면 입력
+
+> 일부 기기에서는 **설정** > **휴대전화 정보** > **소프트웨어 정보** 안에 빌드 번호가 있습니다.
+
+## 충전 중 화면 켜짐 유지 (Stay Awake)
+
+1. **설정** > **개발자 옵션** (위에서 활성화한 메뉴)
+2. **화면 켜짐 유지** (Stay awake) 옵션을 **ON**
+3. 이제 USB 또는 무선 충전 중에는 화면이 자동으로 꺿지지 않습니다
+
+> 충전기를 분리하면 일반 화면 꺿짐 설정이 적용됩니다. 서버를 장시간 운영할 때는 충전기를 연결해두세요.
+
+## 충전 제한 설정 (필수)
+
+폰을 24시간 충전 상태로 두면 배터리가 펽창할 수 있습니다. 최대 충전량을 80%로 제한하면 배터리 수명과 안전성이 크게 향상됩니다.
+
+- **삼성**: **설정** > **배터리** > **배터리 보호** → **최대 80%** 선택
+- **Google Pixel**: **설정** > **배터리** > **배터리 보호** → ON
+
+> 제조사마다 메뉴 이름이 다를 수 있습니다. "배터리 보호" 또는 "충전 제한"으로 검색하세요. 해당 기능이 없는 기기에서는 충전기를 수동으로 관리하거나 스마트 플러그를 활용할 수 있습니다.
+
+## 배터리 최적화에서 Termux 제외
+
+1. Android **설정** > **배터리** (또는 **배터리 및 기기 관리**)
+2. **배터리 최적화** (또는 **앱 절전**) 메뉴 진입
+3. 앱 목록에서 **Termux** 를 찾아서 **최적화하지 않음** (또는 **제한 없음**) 선택
+
+> 메뉴 경로는 제조사(삼성, LG 등)와 Android 버전에 따라 다를 수 있습니다. "배터리 최적화 제외" 또는 "앱 절전 해제"로 검색하면 해당 기기의 정확한 경로를 찾을 수 있습니다.
+
+## Phantom Process Killer 비활성화 (Android 12+)
+
+Android 12 이상에는 **Phantom Process Killer**라는 기능이 포함되어 있어, 백그라운드 프로세스를 자동으로 종료합니다. 이로 인해 Termux에서 실행 중인 `openclaw gateway`, `sshd`, `ttyd` 등이 예고 없이 종료될 수 있습니다.
+
+## 증상
+
+Termux에서 다음과 같은 메시지가 보이면 Android가 프로세스를 강제 종료한 것입니다:
+
+```
+[Process completed (signal 9) - press Enter]
+```
+
+
+
+Signal 9 (SIGKILL)는 어떤 프로세스도 가로채거나 차단할 수 없습니다 — Android가 OS 수준에서 종료한 것입니다.
+
+## 요구사항
+
+- **Android 12 이상** (Android 11 이하는 해당 없음)
+- **Termux**에 `android-tools` 설치 (OpenClaw on Android에 포함)
+
+## 1단계: Wake Lock 활성화
+
+알림 바를 내려서 Termux 알림을 찾으세요. **Acquire wakelock**을 탭하면 Android가 Termux를 중단시키는 것을 방지할 수 있습니다.
+
+
+
+
+
+
+활성화되면 알림에 **"wake lock held"**가 표시되고 버튼이 **Release wakelock**으로 바뀝니다.
+
+> Wake lock만으로는 Phantom Process Killer를 완전히 막을 수 없습니다. 아래 단계를 계속 진행하세요.
+
+## 2단계: 무선 디버깅 활성화
+
+1. **설정** > **개발자 옵션**으로 이동
+2. **무선 디버깅** (Wireless debugging)을 찾아서 활성화
+3. 확인 다이얼로그가 나타나면 — **"이 네트워크에서 항상 허용"**을 체크하고 **허용** 탭
+
+
+
+## 3단계: ADB 설치 (아직 설치하지 않은 경우)
+
+Termux에서 `android-tools`를 설치합니다:
+
+```bash
+pkg install -y android-tools
+```
+
+> OpenClaw on Android를 설치했다면 `android-tools`가 이미 포함되어 있습니다.
+
+## 4단계: ADB 페어링
+
+1. **무선 디버깅** 설정에서 **페어링 코드로 기기 페어링** (Pair device with pairing code) 탭
+2. **Wi-Fi 페어링 코드**와 **IP 주소 및 포트**가 표시된 다이얼로그가 나타남
+
+
+
+3. Termux에서 화면에 표시된 포트와 코드를 사용하여 페어링 명령을 실행합니다:
+
+```bash
+adb pair localhost:<페어링_포트> <페어링_코드>
+```
+
+예시:
+
+```bash
+adb pair localhost:39555 269556
+```
+
+
+
+`Successfully paired`가 표시되면 성공입니다.
+
+## 5단계: ADB 연결
+
+페어링 후 **무선 디버깅** 메인 화면으로 돌아가세요. 상단에 표시된 **IP 주소 및 포트**를 확인합니다 — 이 포트는 페어링 포트와 다릅니다.
+
+
+
+Termux에서 메인 화면에 표시된 포트로 연결합니다:
+
+```bash
+adb connect localhost:<연결_포트>
+```
+
+예시:
+
+```bash
+adb connect localhost:35541
+```
+
+`connected to localhost:35541`이 표시되면 성공입니다.
+
+> 페어링 포트와 연결 포트는 다릅니다. `adb connect`에는 무선 디버깅 메인 화면에 표시된 포트를 사용하세요.
+
+## 6단계: Phantom Process Killer 비활성화
+
+다음 명령을 실행하여 Phantom Process Killer를 비활성화합니다:
+
+```bash
+adb shell "settings put global settings_enable_monitor_phantom_procs false"
+```
+
+설정이 적용되었는지 확인합니다:
+
+```bash
+adb shell "settings get global settings_enable_monitor_phantom_procs"
+```
+
+출력이 `false`이면 Phantom Process Killer가 성공적으로 비활성화된 것입니다.
+
+
+
+## 참고 사항
+
+- 이 설정은 **재부팅해도 유지**됩니다 — 한 번만 하면 됩니다
+- 이 과정을 완료한 후 무선 디버깅을 켜둘 필요는 없습니다. 꺼도 됩니다
+- 일반 앱 동작에는 영향을 주지 않습니다 — Termux의 백그라운드 프로세스가 종료되는 것만 방지합니다
+- 폰을 초기화하면 이 과정을 다시 수행해야 합니다
+
+## 추가 참고
+
+일부 제조사(삼성, 샤오미, 화웨이 등)는 자체적으로 공격적인 배터리 최적화를 적용하여 백그라운드 앱을 종료시킬 수 있습니다. Phantom Process Killer를 비활성화한 후에도 프로세스가 종료되는 경우, [dontkillmyapp.com](https://dontkillmyapp.com)에서 기기별 가이드를 확인하세요.
diff --git a/docs/disable-phantom-process-killer.md b/docs/disable-phantom-process-killer.md
new file mode 100644
index 0000000..8114474
--- /dev/null
+++ b/docs/disable-phantom-process-killer.md
@@ -0,0 +1,163 @@
+# Keeping Processes Alive on Android
+
+OpenClaw runs as a server, so Android's power management and process killing can interfere with stable operation. This guide covers all the settings needed to keep your processes running reliably.
+
+## Enable Developer Options
+
+1. Go to **Settings** > **About phone** (or **Device information**)
+2. Tap **Build number** 7 times
+3. You'll see "Developer mode has been enabled"
+4. Enter your lock screen password if prompted
+
+> On some devices, Build number is under **Settings** > **About phone** > **Software information**.
+
+## Stay Awake While Charging
+
+1. Go to **Settings** > **Developer options** (the menu you just enabled)
+2. Turn on **Stay awake**
+3. The screen will now stay on whenever the device is charging (USB or wireless)
+
+> The screen will still turn off normally when unplugged. Keep the charger connected when running the server for extended periods.
+
+## Set Charge Limit (Required)
+
+Keeping a phone plugged in 24/7 at 100% can cause battery swelling. Limiting the maximum charge to 80% greatly improves battery lifespan and safety.
+
+- **Samsung**: **Settings** > **Battery** > **Battery Protection** → Select **Maximum 80%**
+- **Google Pixel**: **Settings** > **Battery** > **Battery Protection** → ON
+
+> Menu names vary by manufacturer. Search for "battery protection" or "charge limit" in your settings. If your device doesn't have this feature, consider managing the charger manually or using a smart plug.
+
+## Disable Battery Optimization for Termux
+
+1. Go to Android **Settings** > **Battery** (or **Battery and device care**)
+2. Open **Battery optimization** (or **App power management**)
+3. Find **Termux** and set it to **Not optimized** (or **Unrestricted**)
+
+> The exact menu path varies by manufacturer (Samsung, LG, etc.) and Android version. Search your settings for "battery optimization" to find it.
+
+## Disable Phantom Process Killer (Android 12+)
+
+Android 12 and above includes a feature called **Phantom Process Killer** that automatically terminates background processes. This can cause Termux processes like `openclaw gateway`, `sshd`, and `ttyd` to be killed without warning.
+
+## Symptoms
+
+If you see this message in Termux, Android has forcibly killed the process:
+
+```
+[Process completed (signal 9) - press Enter]
+```
+
+
+
+Signal 9 (SIGKILL) cannot be caught or blocked by any process — Android terminated it at the OS level.
+
+## Requirements
+
+- **Android 12 or higher** (Android 11 and below are not affected)
+- **Termux** with `android-tools` installed (included in OpenClaw on Android)
+
+## Step 1: Acquire Wake Lock
+
+Pull down the notification bar and find the Termux notification. Tap **Acquire wakelock** to prevent Android from suspending Termux.
+
+
+
+
+
+
+Once activated, the notification will show **"wake lock held"** and the button changes to **Release wakelock**.
+
+> Wake lock alone is not enough to prevent Phantom Process Killer. Continue with the steps below.
+
+## Step 2: Enable Wireless Debugging
+
+1. Go to **Settings** > **Developer options**
+2. Find and enable **Wireless debugging**
+3. A confirmation dialog will appear — check **"Always allow on this network"** and tap **Allow**
+
+
+
+## Step 3: Install ADB (if not already installed)
+
+In Termux, install `android-tools`:
+
+```bash
+pkg install -y android-tools
+```
+
+> If you installed OpenClaw on Android, `android-tools` is already included.
+
+## Step 4: Pair with ADB
+
+1. In **Wireless debugging** settings, tap **Pair device with pairing code**
+2. A dialog will show the **Wi-Fi pairing code** and **IP address & Port**
+
+
+
+3. In Termux, run the pairing command using the port and code shown on screen:
+
+```bash
+adb pair localhost:
+```
+
+Example:
+
+```bash
+adb pair localhost:39555 269556
+```
+
+
+
+You should see `Successfully paired`.
+
+## Step 5: Connect with ADB
+
+After pairing, go back to the **Wireless debugging** main screen. Note the **IP address & Port** shown at the top — this is different from the pairing port.
+
+
+
+In Termux, connect using the port shown on the main screen:
+
+```bash
+adb connect localhost:
+```
+
+Example:
+
+```bash
+adb connect localhost:35541
+```
+
+You should see `connected to localhost:35541`.
+
+> The pairing port and connection port are different. Use the port shown on the Wireless debugging main screen for `adb connect`.
+
+## Step 6: Disable Phantom Process Killer
+
+Now run the following command to disable Phantom Process Killer:
+
+```bash
+adb shell "settings put global settings_enable_monitor_phantom_procs false"
+```
+
+Verify the setting:
+
+```bash
+adb shell "settings get global settings_enable_monitor_phantom_procs"
+```
+
+If the output is `false`, Phantom Process Killer has been successfully disabled.
+
+
+
+## Notes
+
+- This setting **persists across reboots** — you only need to do this once
+- You do **not** need to keep Wireless debugging enabled after completing these steps. You can turn it off
+- This does not affect normal app behavior — it only prevents Android from killing background processes in Termux
+- If you factory reset your phone, you will need to repeat this process
+
+## Further Reading
+
+Some manufacturers (Samsung, Xiaomi, Huawei, etc.) apply additional aggressive battery optimization that can kill background apps. If you still experience process termination after disabling Phantom Process Killer, check [dontkillmyapp.com](https://dontkillmyapp.com) for device-specific guides.
diff --git a/docs/images/signal9/01-signal9-killed.png b/docs/images/signal9/01-signal9-killed.png
new file mode 100644
index 0000000..e20176d
Binary files /dev/null and b/docs/images/signal9/01-signal9-killed.png differ
diff --git a/docs/images/signal9/02-termux-acquire-wakelock.png b/docs/images/signal9/02-termux-acquire-wakelock.png
new file mode 100644
index 0000000..58d6726
Binary files /dev/null and b/docs/images/signal9/02-termux-acquire-wakelock.png differ
diff --git a/docs/images/signal9/03-termux-wakelock-held.png b/docs/images/signal9/03-termux-wakelock-held.png
new file mode 100644
index 0000000..f7fa614
Binary files /dev/null and b/docs/images/signal9/03-termux-wakelock-held.png differ
diff --git a/docs/images/signal9/04-wireless-debugging-allow.png b/docs/images/signal9/04-wireless-debugging-allow.png
new file mode 100644
index 0000000..970167c
Binary files /dev/null and b/docs/images/signal9/04-wireless-debugging-allow.png differ
diff --git a/docs/images/signal9/05-pairing-code-dialog.png b/docs/images/signal9/05-pairing-code-dialog.png
new file mode 100644
index 0000000..5db4262
Binary files /dev/null and b/docs/images/signal9/05-pairing-code-dialog.png differ
diff --git a/docs/images/signal9/06-adb-pair-success.png b/docs/images/signal9/06-adb-pair-success.png
new file mode 100644
index 0000000..3bfd3ec
Binary files /dev/null and b/docs/images/signal9/06-adb-pair-success.png differ
diff --git a/docs/images/signal9/07-wireless-debugging-paired.png b/docs/images/signal9/07-wireless-debugging-paired.png
new file mode 100644
index 0000000..bc19b50
Binary files /dev/null and b/docs/images/signal9/07-wireless-debugging-paired.png differ
diff --git a/docs/images/signal9/08-adb-disable-ppk-done.png b/docs/images/signal9/08-adb-disable-ppk-done.png
new file mode 100644
index 0000000..5395a24
Binary files /dev/null and b/docs/images/signal9/08-adb-disable-ppk-done.png differ
diff --git a/docs/termux-ssh-guide.ko.md b/docs/termux-ssh-guide.ko.md
index 060f458..ab9868a 100644
--- a/docs/termux-ssh-guide.ko.md
+++ b/docs/termux-ssh-guide.ko.md
@@ -35,27 +35,13 @@ Retype new password: 1234 ← 같은 비밀번호 다시 입력
> **중요**: `sshd`는 SSH가 아닌, 폰의 Termux 앱에서 직접 실행하세요.
-게이트웨이가 이미 탭 1에서 실행 중이라면 새 탭이 필요합니다. 하단 메뉴바의 **햄버거 아이콘(☰)**을 탭하거나, 화면 왼쪽 가장자리에서 오른쪽으로 스와이프하면 (하단 메뉴바 위 영역) 사이드 메뉴가 나타납니다. **NEW SESSION**을 눌러 새 탭을 추가하세요.
-
-
-
-새 탭에서 실행합니다:
-
```bash
sshd
```
아무 메시지 없이 프롬프트(`$`)가 다시 나오면 정상입니다.
-권장 탭 구성:
-
-- **탭 1**: `openclaw gateway` — 게이트웨이 실행
-
-
-
-- **탭 2**: `sshd` — 컴퓨터에서 SSH 접속용
-
-
+
## 4단계: IP 주소 확인
diff --git a/docs/termux-ssh-guide.md b/docs/termux-ssh-guide.md
index 3a94bb4..a2600d0 100644
--- a/docs/termux-ssh-guide.md
+++ b/docs/termux-ssh-guide.md
@@ -35,27 +35,13 @@ Retype new password: 1234 ← type the same password again
> **Important**: Run `sshd` directly in the Termux app on your phone, not via SSH.
-If the gateway is already running in Tab 1, you'll need a new tab. Tap the **hamburger icon (☰)** on the bottom menu bar, or swipe right from the left edge of the screen (above the bottom menu bar) to open the side menu. Then tap **NEW SESSION**.
-
-
-
-In the new tab, run:
-
```bash
sshd
```
If the prompt (`$`) returns with no error message, it's working.
-Recommended tab setup:
-
-- **Tab 1**: `openclaw gateway` — Run the gateway
-
-
-
-- **Tab 2**: `sshd` — Allow SSH access from your computer
-
-
+
## Step 4: Find the Phone's IP Address
diff --git a/docs/troubleshooting.ko.md b/docs/troubleshooting.ko.md
old mode 100755
new mode 100644
index ef293f3..4a506e4
--- a/docs/troubleshooting.ko.md
+++ b/docs/troubleshooting.ko.md
@@ -116,12 +116,14 @@ source ~/.bashrc
또는 Termux 앱을 완전히 종료했다가 다시 여세요.
-## "Cannot find module bionic-compat.js" 에러
+## "Cannot find module glibc-compat.js" 에러
```
-Error: Cannot find module '/data/data/com.termux/files/home/.openclaw-lite/patches/bionic-compat.js'
+Error: Cannot find module '/data/data/com.termux/files/home/.openclaw-lite/patches/glibc-compat.js'
```
+> **참고**: 이 문제는 v1.0.0 이전(Bionic) 설치에서만 발생합니다. v1.0.0+(glibc)에서는 `glibc-compat.js`가 node 래퍼 스크립트에 의해 로딩되므로 `NODE_OPTIONS`를 사용하지 않습니다.
+
### 원인
`~/.bashrc`의 `NODE_OPTIONS` 환경변수가 이전 설치 경로(`.openclaw-lite`)를 참조하고 있습니다. 프로젝트명이 "OpenClaw Lite"였던 이전 버전에서 업데이트한 경우 발생합니다.
@@ -131,7 +133,7 @@ Error: Cannot find module '/data/data/com.termux/files/home/.openclaw-lite/patch
업데이터를 실행하면 환경변수 블록이 갱신됩니다:
```bash
-oaupdate && source ~/.bashrc
+oa --update && source ~/.bashrc
```
또는 수동으로 수정:
@@ -174,7 +176,9 @@ Reason: global update
### 원인
-`openclaw update`가 npm으로 패키지를 업데이트할 때, npm을 서브프로세스로 실행합니다. `sharp` 네이티브 모듈 컴파일에 필요한 Termux 전용 빌드 환경변수(`CXXFLAGS`, `GYP_DEFINES`, `CPATH`)가 `~/.bashrc`에 설정되어 있지만, 해당 서브프로세스 환경에서는 자동으로 적용되지 않아 빌드가 실패합니다.
+**v1.0.0+(glibc)**: `sharp` 모듈은 프리빌트 바이너리(`@img/sharp-linux-arm64`)를 사용하며 glibc 환경에서 네이티브로 로딩됩니다. 이 에러는 드문 — 주로 프리빌트 바이너리가 누락되거나 손상된 경우입니다.
+
+**v1.0.0 이전(Bionic)**: `openclaw update`가 npm을 서브프로세스로 실행할 때, Termux 전용 빌드 환경변수(`CXXFLAGS`, `GYP_DEFINES`)가 서브프로세스 환경에서 사용 불가하여 네이티브 모듈 컴파일이 실패합니다.
### 영향
@@ -188,10 +192,34 @@ Reason: global update
bash ~/.openclaw-android/scripts/build-sharp.sh
```
-또는 `openclaw update` 대신 `oaupdate`를 사용하면, 필요한 환경변수를 자동으로 설정하고 sharp 빌드까지 처리합니다:
+또는 `openclaw update` 대신 `oa --update`를 사용하면 sharp를 자동으로 처리합니다:
```bash
-oaupdate && source ~/.bashrc
+oa --update && source ~/.bashrc
+```
+
+## `clawdhub` 실행 시 "Cannot find package 'undici'" 에러
+
+```
+Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'undici' imported from /data/data/com.termux/files/usr/lib/node_modules/clawdhub/dist/http.js
+```
+
+### 원인
+
+Node.js v24+ Termux 환경에서는 `undici` 패키지가 Node.js에 번들되지 않습니다. `clawdhub`가 HTTP 요청에 `undici`를 사용하지만 찾을 수 없어 실패합니다.
+
+### 해결 방법
+
+업데이터를 실행하면 `clawdhub`와 `undici` 의존성이 자동으로 설치됩니다:
+
+```bash
+oa --update && source ~/.bashrc
+```
+
+또는 수동으로 수정:
+
+```bash
+cd $(npm root -g)/clawdhub && npm install undici
```
## "not supported on android" 에러
@@ -200,32 +228,77 @@ oaupdate && source ~/.bashrc
Gateway status failed: Error: Gateway service install not supported on android
```
+> **참고**: 이 문제는 v1.0.0 이전(Bionic) 설치에서만 발생합니다. v1.0.0+(glibc)에서는 Node.js가 `process.platform`을 `'linux'`으로 보고하므로 이 에러가 발생하지 않습니다.
+
### 원인
-`bionic-compat.js`의 `process.platform` 오버라이드가 적용되지 않은 상태입니다.
+**v1.0.0 이전(Bionic)**: `glibc-compat.js`의 `process.platform` 오버라이드가 적용되지 않은 상태입니다. `NODE_OPTIONS`가 설정되지 않았기 때문입니다.
### 해결 방법
-`NODE_OPTIONS` 환경변수가 설정되어 있는지 확인:
+어떤 Node.js가 사용되고 있는지 확인:
```bash
-echo $NODE_OPTIONS
+node -e "console.log(process.platform)"
```
-비어있으면 환경변수를 로드하세요:
+`android`가 출력되면 glibc node 래퍼가 사용되지 않고 있는 것입니다. 환경변수를 로드하세요:
```bash
source ~/.bashrc
```
-`NODE_OPTIONS`가 설정되어 있는데도 에러가 나면, `bionic-compat.js` 파일이 최신인지 확인:
+여전히 `android`가 출력되면, 최신 버전으로 업데이트하세요 (v1.0.0+는 glibc를 사용하여 이 문제를 영구적으로 해결합니다):
```bash
-node -e "console.log(process.platform)"
+oa --update && source ~/.bashrc
+```
+
+## `openclaw update` 시 node-llama-cpp 빌드 에러
+
```
+[node-llama-cpp] Cloning ggml-org/llama.cpp (local bundle)
+npm error 48%
+Update Result: ERROR
+```
+
+### 원인
+
+OpenClaw이 npm으로 업데이트할 때, `node-llama-cpp`의 postinstall 스크립트가 `llama.cpp` 소스를 clone하고 컴파일을 시도합니다. Termux의 빌드 툴체인(`cmake`, `clang`)이 Bionic으로 링크되어 있고 Node.js는 glibc로 실행되므로 — 두 환경이 네이티브 컴파일에 호환되지 않아 실패합니다.
+
+### 영향
+
+**이 에러는 무해합니다.** 프리빌트 `node-llama-cpp` 바이너리(`@node-llama-cpp/linux-arm64`)가 이미 설치되어 있으며 glibc 환경에서 정상 작동합니다. 실패한 소스 빌드가 프리빌트 바이너리를 덮어쓰지 않습니다.
+
+node-llama-cpp는 선택적 로컬 임베딩에 사용됩니다. 프리빌트 바이너리가 로딩되지 않으면 OpenClaw이 원격 임베딩 프로바이더(OpenAI, Gemini 등)로 자동 fallback합니다.
+
+### 해결 방법
+
+조치가 필요 없습니다. 이 에러는 안전하게 무시할 수 있습니다. 프리빌트 바이너리가 정상 작동하는지 확인하려면:
+
+```bash
+node -e "require('$(npm root -g)/openclaw/node_modules/@node-llama-cpp/linux-arm64/bins/linux-arm64/llama-addon.node'); console.log('OK')"
+```
+
+## OpenCode 설치 시 EACCES 권한 에러
+
+```
+EACCES: Permission denied while installing opencode-ai
+Failed to install 118 packages
+```
+
+### 원인
+
+Bun이 패키지 설치 시 하드링크와 심링크를 생성하려고 시도합니다. Android 파일시스템이 이러한 작업을 제한하여 의존성 패키지에서 `EACCES` 에러가 발생합니다.
+
+### 영향
+
+**이 에러는 무해합니다.** 메인 바이너리(`opencode`)는 의존성 링크 실패에도 불구하고 정상적으로 설치됩니다. ld.so 결합과 proot 래퍼가 실행을 처리합니다.
+
+### 해결 방법
-`android`가 출력되면 파일이 오래된 버전입니다. 재설치하세요:
+조치가 필요 없습니다. OpenCode가 정상 작동하는지 확인:
```bash
-curl -sL https://raw.githubusercontent.com/AidanPark/openclaw-android/main/bootstrap.sh | bash
+opencode --version
```
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
old mode 100755
new mode 100644
index 20a4c3f..4b8a0f6
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -116,12 +116,14 @@ source ~/.bashrc
Or fully close and reopen the Termux app.
-## "Cannot find module bionic-compat.js" error
+## "Cannot find module glibc-compat.js" error
```
-Error: Cannot find module '/data/data/com.termux/files/home/.openclaw-lite/patches/bionic-compat.js'
+Error: Cannot find module '/data/data/com.termux/files/home/.openclaw-lite/patches/glibc-compat.js'
```
+> **Note**: This issue only affects pre-1.0.0 (Bionic) installations. In v1.0.0+ (glibc), `glibc-compat.js` is loaded by the node wrapper script, not `NODE_OPTIONS`.
+
### Cause
The `NODE_OPTIONS` environment variable in `~/.bashrc` still references the old installation path (`.openclaw-lite`). This happens when updating from an older version where the project was named "OpenClaw Lite".
@@ -131,7 +133,7 @@ The `NODE_OPTIONS` environment variable in `~/.bashrc` still references the old
Run the updater to refresh the environment variable block:
```bash
-oaupdate && source ~/.bashrc
+oa --update && source ~/.bashrc
```
Or manually fix it:
@@ -174,7 +176,9 @@ Reason: global update
### Cause
-When `openclaw update` runs npm to update the package, it spawns npm as a subprocess. The Termux-specific build environment variables required to compile `sharp`'s native module (`CXXFLAGS`, `GYP_DEFINES`, `CPATH`) are set in `~/.bashrc` but are not automatically available in that subprocess context.
+**v1.0.0+ (glibc)**: The `sharp` module uses prebuilt binaries (`@img/sharp-linux-arm64`) that load natively under the glibc environment. This error is rare — it typically means the prebuilt binary is missing or corrupted.
+
+**Pre-1.0.0 (Bionic)**: When `openclaw update` ran npm as a subprocess, the Termux-specific build environment variables (`CXXFLAGS`, `GYP_DEFINES`) were not available in the subprocess context, causing the native module compilation to fail.
### Impact
@@ -188,10 +192,34 @@ After the update, manually rebuild `sharp` using the provided script:
bash ~/.openclaw-android/scripts/build-sharp.sh
```
-Alternatively, use `oaupdate` instead of `openclaw update` — it sets the required environment variables and rebuilds sharp automatically:
+Alternatively, use `oa --update` instead of `openclaw update` — it handles sharp automatically:
+
+```bash
+oa --update && source ~/.bashrc
+```
+
+## `clawdhub` fails with "Cannot find package 'undici'"
+
+```
+Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'undici' imported from /data/data/com.termux/files/usr/lib/node_modules/clawdhub/dist/http.js
+```
+
+### Cause
+
+Node.js v24+ on Termux doesn't bundle the `undici` package, which `clawdhub` depends on for HTTP requests.
+
+### Solution
+
+Run the updater to automatically install `clawdhub` and its `undici` dependency:
+
+```bash
+oa --update && source ~/.bashrc
+```
+
+Or fix it manually:
```bash
-oaupdate && source ~/.bashrc
+cd $(npm root -g)/clawdhub && npm install undici
```
## "not supported on android" error
@@ -200,32 +228,77 @@ oaupdate && source ~/.bashrc
Gateway status failed: Error: Gateway service install not supported on android
```
+> **Note**: This issue only affects pre-1.0.0 (Bionic) installations. In v1.0.0+ (glibc), Node.js natively reports `process.platform` as `'linux'`, so this error does not occur.
+
### Cause
-The `process.platform` override in `bionic-compat.js` is not being applied.
+**Pre-1.0.0 (Bionic)**: The `process.platform` override in `glibc-compat.js` is not being applied because `NODE_OPTIONS` is not set.
### Solution
-Check if the `NODE_OPTIONS` environment variable is set:
+Check which Node.js is being used:
```bash
-echo $NODE_OPTIONS
+node -e "console.log(process.platform)"
```
-If empty, load the environment:
+If it prints `android`, the glibc node wrapper is not being used. Load the environment:
```bash
source ~/.bashrc
```
-If `NODE_OPTIONS` is set but the error persists, check if the file is up to date:
+If it still prints `android`, update to the latest version (v1.0.0+ uses glibc and resolves this permanently):
```bash
-node -e "console.log(process.platform)"
+oa --update && source ~/.bashrc
+```
+
+## `openclaw update` fails with node-llama-cpp build error
+
+```
+[node-llama-cpp] Cloning ggml-org/llama.cpp (local bundle)
+npm error 48%
+Update Result: ERROR
+```
+
+### Cause
+
+When OpenClaw updates via npm, `node-llama-cpp`'s postinstall script attempts to clone and compile `llama.cpp` from source. This fails on Termux because the build toolchain (`cmake`, `clang`) is linked against Bionic, while Node.js runs under glibc — the two are incompatible for native compilation.
+
+### Impact
+
+**This error is harmless.** The prebuilt `node-llama-cpp` binaries (`@node-llama-cpp/linux-arm64`) are already installed and work correctly under the glibc environment. The failed source build does not overwrite them.
+
+Node-llama-cpp is used for optional local embeddings. If the prebuilt binaries don't load, OpenClaw automatically falls back to remote embedding providers (OpenAI, Gemini, etc.).
+
+### Solution
+
+No action needed. The error can be safely ignored. To verify that the prebuilt binaries are working:
+
+```bash
+node -e "require('$(npm root -g)/openclaw/node_modules/@node-llama-cpp/linux-arm64/bins/linux-arm64/llama-addon.node'); console.log('OK')"
+```
+
+## OpenCode install shows EACCES permission errors
+
```
+EACCES: Permission denied while installing opencode-ai
+Failed to install 118 packages
+```
+
+### Cause
+
+Bun attempts to create hardlinks and symlinks when installing packages. Android's filesystem restricts these operations, causing `EACCES` errors for dependency packages.
+
+### Impact
+
+**These errors are harmless.** The main binary (`opencode`) is installed correctly despite the dependency link failures. The ld.so concatenation and proot wrapper handle execution.
+
+### Solution
-If it prints `android`, the file is outdated. Reinstall:
+No action needed. Verify that OpenCode works:
```bash
-curl -sL https://raw.githubusercontent.com/AidanPark/openclaw-android/main/bootstrap.sh | bash
+opencode --version
```
diff --git a/doit.md b/doit.md
new file mode 100644
index 0000000..91eb50d
--- /dev/null
+++ b/doit.md
@@ -0,0 +1,17 @@
+
+작업 지시서
+
+1. openclaw 앱 설치한다.
+2. 앱 설치후 앱의 터미널 창에서 'openclaw onboard' 를 진행한다.
+2. 'openclaw onboard' 에서 아무것도 설치하지 않는다.
+3. llm 은 제미나이, 키는 AIzaSyBWxFwDi_EVCALxjhjesQBcLyxzpVttMlU
+4. 'openclaw onboard' 에서 그밖의 설정 필요한 것이 있으면 정보입력을 최소화 하는 방향으로 설정.
+5. 'openclaw onboard' 가 끝나면 'openclaw gateway' 실행.
+6. 실행이 잘 되면 메인화면으로 가서 탭 전환이 잘 되는지 확인.
+7. settings 와 dashboard 는 openclaw 설치나 live 상황을 잘 반영하고 있나 확인.
+8. oa 명령에 해당하는 ui 기능이 제공되어야 한다.
+9. 테스트 항목을 작성하고 테스트 항목에 따라 테스트를 진행한다.테스트 항목에는 모든 기능 확인내용이 들어가야 한다.
+10. 오류가 발생하면 수정하고 오류발생 직전단계 부터 다시 테스트를 진행.
+11. 작업이 완료되면 완료보고서를 작성하고 완료시각을 적는다.
+12. 앱네임 변경. 'Claw on Android'
+12. 커밋 푸시한다. 커밋 메시지 승인은 미리 승인함.
\ No newline at end of file
diff --git a/index.html b/index.html
deleted file mode 100644
index fdd80fe..0000000
--- a/index.html
+++ /dev/null
@@ -1,1318 +0,0 @@
-
-
-
-
-
-
-My OpenClaw Hub — Manage OpenClaw Servers from Your Browser
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
My OpenClaw Hub
-
-
- EN
- 한국어
- 中文
- 日本語
- ES
- FR
- DE
- PT
- RU
- عربي
-
-
-
-
-
-
-
My OpenClaw Connections
-
+ New Connection
-
-
-
-
-
Your settings are saved locally in your browser and never sent to any server.
-
-
-
-
Connection Info
-
-
-
-
SSH Connect
-
-
Connect to Termux via SSH ? Copy the command below and run it in a terminal on your PC to access the host's Termux shell. See the SSH Setup Guide for details.
-
-
- Copy
-
-
-
-
Dashboard Connect
-
-
-
Run SSH Tunnel on your PC ? Copy the command below and run it in a terminal on your PC. Keep it running while you use the dashboard.
-
-
- Copy
-
-
-
Open Dashboard
-
-
-
-
File Transfer
-
-
Download File from Phone
-
-
-
- Copy
-
-
-
Download Folder from Phone
-
-
-
- Copy
-
-
-
-
-
-
-
-
-
-
-
diff --git a/install-tools.sh b/install-tools.sh
new file mode 100644
index 0000000..62458d3
--- /dev/null
+++ b/install-tools.sh
@@ -0,0 +1,216 @@
+#!/usr/bin/env bash
+# =============================================================================
+# install-tools.sh — 번들 제공 도구 설치
+#
+# oa --install 로 실행. 초기 설치 시 설치하지 않은 도구를 나중에 설치할 수 있다.
+# 이미 설치된 도구는 [INSTALLED]로 표시하고 건너뛴다.
+# =============================================================================
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BOLD='\033[1m'
+NC='\033[0m'
+
+PROJECT_DIR="$HOME/.openclaw-android"
+PLATFORM_MARKER="$PROJECT_DIR/.platform"
+OA_VERSION="1.0.6"
+REPO_TARBALL="https://github.com/AidanPark/openclaw-android/archive/refs/heads/main.tar.gz"
+
+echo ""
+echo -e "${BOLD}========================================${NC}"
+echo -e "${BOLD} OpenClaw on Android - Install Tools${NC}"
+echo -e "${BOLD}========================================${NC}"
+echo ""
+
+# --- Pre-checks ---
+if [ -z "${PREFIX:-}" ]; then
+ echo -e "${RED}[FAIL]${NC} Not running in Termux (\$PREFIX not set)"
+ exit 1
+fi
+
+if ! command -v curl &>/dev/null; then
+ echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl"
+ exit 1
+fi
+
+if [ -f "$PROJECT_DIR/scripts/lib.sh" ]; then
+ source "$PROJECT_DIR/scripts/lib.sh"
+fi
+
+if ! declare -f ask_yn &>/dev/null; then
+ ask_yn() {
+ local prompt="$1"
+ local reply
+ read -rp "$prompt [Y/n] " reply < /dev/tty
+ [[ "${reply:-}" =~ ^[Nn]$ ]] && return 1
+ return 0
+ }
+fi
+
+IS_GLIBC=false
+if [ -f "$PROJECT_DIR/.glibc-arch" ]; then
+ IS_GLIBC=true
+fi
+
+# --- Detect installed tools ---
+echo -e "${BOLD}Checking installed tools...${NC}"
+echo ""
+
+declare -A TOOL_STATUS
+
+check_tool() {
+ local name="$1"
+ local cmd="$2"
+ if command -v "$cmd" &>/dev/null; then
+ TOOL_STATUS["$name"]="installed"
+ echo -e " ${GREEN}[INSTALLED]${NC} $name"
+ else
+ TOOL_STATUS["$name"]="not_installed"
+ echo -e " ${YELLOW}[NOT INSTALLED]${NC} $name"
+ fi
+}
+
+check_tool "tmux" "tmux"
+check_tool "ttyd" "ttyd"
+check_tool "dufs" "dufs"
+check_tool "android-tools" "adb"
+check_tool "Chromium" "chromium-browser"
+check_tool "code-server" "code-server"
+if [ "$IS_GLIBC" = true ]; then
+ check_tool "OpenCode" "opencode"
+fi
+check_tool "Claude Code" "claude"
+check_tool "Gemini CLI" "gemini"
+check_tool "Codex CLI" "codex"
+
+echo ""
+
+# --- Check if anything to install ---
+HAS_UNINSTALLED=false
+for status in "${TOOL_STATUS[@]}"; do
+ if [ "$status" = "not_installed" ]; then
+ HAS_UNINSTALLED=true
+ break
+ fi
+done
+
+if [ "$HAS_UNINSTALLED" = false ]; then
+ echo -e "${GREEN}All available tools are already installed.${NC}"
+ echo ""
+ exit 0
+fi
+
+# --- Collect selections ---
+echo -e "${BOLD}Select tools to install:${NC}"
+echo ""
+
+INSTALL_TMUX=false
+INSTALL_TTYD=false
+INSTALL_DUFS=false
+INSTALL_ANDROID_TOOLS=false
+INSTALL_CODE_SERVER=false
+INSTALL_OPENCODE=false
+INSTALL_CLAUDE_CODE=false
+INSTALL_GEMINI_CLI=false
+INSTALL_CODEX_CLI=false
+INSTALL_CHROMIUM=false
+
+[ "${TOOL_STATUS[tmux]}" = "not_installed" ] && ask_yn " Install tmux (terminal multiplexer)?" && INSTALL_TMUX=true || true
+[ "${TOOL_STATUS[ttyd]}" = "not_installed" ] && ask_yn " Install ttyd (web terminal)?" && INSTALL_TTYD=true || true
+[ "${TOOL_STATUS[dufs]}" = "not_installed" ] && ask_yn " Install dufs (file server)?" && INSTALL_DUFS=true || true
+[ "${TOOL_STATUS[android-tools]}" = "not_installed" ] && ask_yn " Install android-tools (adb)?" && INSTALL_ANDROID_TOOLS=true || true
+[ "${TOOL_STATUS[Chromium]}" = "not_installed" ] && ask_yn " Install Chromium (browser automation, ~400MB)?" && INSTALL_CHROMIUM=true || true
+[ "${TOOL_STATUS[code-server]}" = "not_installed" ] && ask_yn " Install code-server (browser IDE)?" && INSTALL_CODE_SERVER=true || true
+if [ "$IS_GLIBC" = true ] && [ "${TOOL_STATUS[OpenCode]}" = "not_installed" ]; then
+ ask_yn " Install OpenCode (AI coding assistant)?" && INSTALL_OPENCODE=true || true
+fi
+[ "${TOOL_STATUS[Claude Code]}" = "not_installed" ] && ask_yn " Install Claude Code CLI?" && INSTALL_CLAUDE_CODE=true || true
+[ "${TOOL_STATUS[Gemini CLI]}" = "not_installed" ] && ask_yn " Install Gemini CLI?" && INSTALL_GEMINI_CLI=true || true
+[ "${TOOL_STATUS[Codex CLI]}" = "not_installed" ] && ask_yn " Install Codex CLI?" && INSTALL_CODEX_CLI=true || true
+
+# --- Check if anything selected ---
+ANYTHING_SELECTED=false
+for var in INSTALL_TMUX INSTALL_TTYD INSTALL_DUFS INSTALL_ANDROID_TOOLS \
+ INSTALL_CHROMIUM INSTALL_CODE_SERVER INSTALL_OPENCODE INSTALL_CLAUDE_CODE \
+ INSTALL_GEMINI_CLI INSTALL_CODEX_CLI; do
+ if [ "${!var}" = true ]; then
+ ANYTHING_SELECTED=true
+ break
+ fi
+done
+
+if [ "$ANYTHING_SELECTED" = false ]; then
+ echo ""
+ echo "No tools selected."
+ exit 0
+fi
+
+# --- Download scripts (needed for code-server and OpenCode) ---
+NEEDS_TARBALL=false
+if [ "$INSTALL_CODE_SERVER" = true ] || [ "$INSTALL_OPENCODE" = true ] || [ "$INSTALL_CHROMIUM" = true ]; then
+ NEEDS_TARBALL=true
+fi
+
+if [ "$NEEDS_TARBALL" = true ]; then
+ echo ""
+ echo "Downloading install scripts..."
+ mkdir -p "$PREFIX/tmp"
+ RELEASE_TMP=$(mktemp -d "$PREFIX/tmp/oa-install.XXXXXX") || {
+ echo -e "${RED}[FAIL]${NC} Failed to create temp directory"
+ exit 1
+ }
+ trap 'rm -rf "$RELEASE_TMP"' EXIT
+
+ if curl -sfL "$REPO_TARBALL" | tar xz -C "$RELEASE_TMP" --strip-components=1; then
+ echo -e "${GREEN}[OK]${NC} Downloaded install scripts"
+ else
+ echo -e "${RED}[FAIL]${NC} Failed to download scripts"
+ exit 1
+ fi
+fi
+
+# --- Install selected tools ---
+echo ""
+echo -e "${BOLD}Installing selected tools...${NC}"
+echo ""
+
+[ "$INSTALL_TMUX" = true ] && echo "Installing tmux..." && pkg install -y tmux && echo -e "${GREEN}[OK]${NC} tmux installed" || true
+[ "$INSTALL_TTYD" = true ] && echo "Installing ttyd..." && pkg install -y ttyd && echo -e "${GREEN}[OK]${NC} ttyd installed" || true
+[ "$INSTALL_DUFS" = true ] && echo "Installing dufs..." && pkg install -y dufs && echo -e "${GREEN}[OK]${NC} dufs installed" || true
+[ "$INSTALL_ANDROID_TOOLS" = true ] && echo "Installing android-tools..." && pkg install -y android-tools && echo -e "${GREEN}[OK]${NC} android-tools installed" || true
+
+if [ "$INSTALL_CODE_SERVER" = true ]; then
+ mkdir -p "$PROJECT_DIR/patches"
+ cp "$RELEASE_TMP/patches/argon2-stub.js" "$PROJECT_DIR/patches/argon2-stub.js"
+ if bash "$RELEASE_TMP/scripts/install-code-server.sh" install; then
+ echo -e "${GREEN}[OK]${NC} code-server installed"
+ else
+ echo -e "${YELLOW}[WARN]${NC} code-server installation failed (non-critical)"
+ fi
+fi
+
+if [ "$INSTALL_OPENCODE" = true ]; then
+ if bash "$RELEASE_TMP/scripts/install-opencode.sh"; then
+ echo -e "${GREEN}[OK]${NC} OpenCode installed"
+ else
+ echo -e "${YELLOW}[WARN]${NC} OpenCode installation failed (non-critical)"
+ fi
+fi
+
+if [ "$INSTALL_CHROMIUM" = true ]; then
+ if bash "$RELEASE_TMP/scripts/install-chromium.sh" install; then
+ echo -e "${GREEN}[OK]${NC} Chromium installed"
+ else
+ echo -e "${YELLOW}[WARN]${NC} Chromium installation failed (non-critical)"
+ fi
+fi
+
+[ "$INSTALL_CLAUDE_CODE" = true ] && echo "Installing Claude Code..." && npm install -g @anthropic-ai/claude-code && echo -e "${GREEN}[OK]${NC} Claude Code installed" || true
+[ "$INSTALL_GEMINI_CLI" = true ] && echo "Installing Gemini CLI..." && npm install -g @google/gemini-cli && echo -e "${GREEN}[OK]${NC} Gemini CLI installed" || true
+[ "$INSTALL_CODEX_CLI" = true ] && echo "Installing Codex CLI..." && npm install -g @openai/codex && echo -e "${GREEN}[OK]${NC} Codex CLI installed" || true
+
+echo ""
+echo -e "${GREEN}${BOLD} Installation Complete!${NC}"
+echo ""
diff --git a/install.sh b/install.sh
index de8521d..0eb88d0 100755
--- a/install.sh
+++ b/install.sh
@@ -1,118 +1,133 @@
#!/usr/bin/env bash
-# install.sh - One-click installer for OpenClaw on Termux (Android)
-# Usage: bash install.sh
set -euo pipefail
-GREEN='\033[0;32m'
-BOLD='\033[1m'
-NC='\033[0m'
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/scripts/lib.sh"
echo ""
echo -e "${BOLD}========================================${NC}"
-echo -e "${BOLD} OpenClaw on Android - Installer${NC}"
+echo -e "${BOLD} OpenClaw on Android - Installer v${OA_VERSION}${NC}"
echo -e "${BOLD}========================================${NC}"
echo ""
-echo "This script installs OpenClaw on Termux without proot-distro."
+echo "This script installs OpenClaw on Termux with platform-aware architecture."
echo ""
step() {
echo ""
- echo -e "${BOLD}[$1/7] $2${NC}"
+ echo -e "${BOLD}[$1/8] $2${NC}"
echo "----------------------------------------"
}
-# ─────────────────────────────────────────────
step 1 "Environment Check"
+if command -v termux-wake-lock &>/dev/null; then
+ termux-wake-lock 2>/dev/null || true
+ echo -e "${GREEN}[OK]${NC} Termux wake lock enabled"
+fi
bash "$SCRIPT_DIR/scripts/check-env.sh"
-# ─────────────────────────────────────────────
-step 2 "Installing Dependencies"
-bash "$SCRIPT_DIR/scripts/install-deps.sh"
-
-# ─────────────────────────────────────────────
-step 3 "Setting Up Paths"
+step 2 "Platform Selection"
+SELECTED_PLATFORM="openclaw"
+echo -e "${GREEN}[OK]${NC} Platform: OpenClaw"
+load_platform_config "$SELECTED_PLATFORM" "$SCRIPT_DIR"
+
+step 3 "Optional Tools Selection (L3)"
+INSTALL_TMUX=false
+INSTALL_TTYD=false
+INSTALL_DUFS=false
+INSTALL_ANDROID_TOOLS=false
+INSTALL_CODE_SERVER=false
+INSTALL_OPENCODE=false
+INSTALL_CLAUDE_CODE=false
+INSTALL_GEMINI_CLI=false
+INSTALL_CODEX_CLI=false
+INSTALL_CHROMIUM=false
+
+if ask_yn "Install tmux (terminal multiplexer)?"; then INSTALL_TMUX=true; fi
+if ask_yn "Install ttyd (web terminal)?"; then INSTALL_TTYD=true; fi
+if ask_yn "Install dufs (file server)?"; then INSTALL_DUFS=true; fi
+if ask_yn "Install android-tools (adb)?"; then INSTALL_ANDROID_TOOLS=true; fi
+if ask_yn "Install Chromium (browser automation for OpenClaw, ~400MB)?"; then INSTALL_CHROMIUM=true; fi
+if ask_yn "Install code-server (browser IDE)?"; then INSTALL_CODE_SERVER=true; fi
+if ask_yn "Install OpenCode (AI coding assistant)?"; then INSTALL_OPENCODE=true; fi
+if ask_yn "Install Claude Code CLI?"; then INSTALL_CLAUDE_CODE=true; fi
+if ask_yn "Install Gemini CLI?"; then INSTALL_GEMINI_CLI=true; fi
+if ask_yn "Install Codex CLI?"; then INSTALL_CODEX_CLI=true; fi
+
+step 4 "Core Infrastructure (L1)"
+bash "$SCRIPT_DIR/scripts/install-infra-deps.sh"
bash "$SCRIPT_DIR/scripts/setup-paths.sh"
-# ─────────────────────────────────────────────
-step 4 "Configuring Environment Variables"
-bash "$SCRIPT_DIR/scripts/setup-env.sh"
+step 5 "Platform Runtime Dependencies (L2)"
+[ "${PLATFORM_NEEDS_GLIBC:-false}" = true ] && bash "$SCRIPT_DIR/scripts/install-glibc.sh" || true
+[ "${PLATFORM_NEEDS_NODEJS:-false}" = true ] && bash "$SCRIPT_DIR/scripts/install-nodejs.sh" || true
+[ "${PLATFORM_NEEDS_BUILD_TOOLS:-false}" = true ] && bash "$SCRIPT_DIR/scripts/install-build-tools.sh" || true
+[ "${PLATFORM_NEEDS_PROOT:-false}" = true ] && pkg install -y proot || true
-# Source the new environment for current session
+# Source environment for current session (needed by platform install)
+GLIBC_NODE_DIR="$PROJECT_DIR/node"
+export PATH="$GLIBC_NODE_DIR/bin:$HOME/.local/bin:$PATH"
export TMPDIR="$PREFIX/tmp"
export TMP="$TMPDIR"
export TEMP="$TMPDIR"
-export NODE_OPTIONS="-r $HOME/.openclaw-android/patches/bionic-compat.js"
-export CONTAINER=1
-export CFLAGS="-Wno-error=implicit-function-declaration"
-export CXXFLAGS="-include $HOME/.openclaw-android/patches/termux-compat.h"
-export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX"
-export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include"
-
-# ─────────────────────────────────────────────
-step 5 "Installing OpenClaw"
-
-# Apply bionic-compat.js first (needed for npm install)
-echo "Copying compatibility patches..."
-mkdir -p "$HOME/.openclaw-android/patches"
-cp "$SCRIPT_DIR/patches/bionic-compat.js" "$HOME/.openclaw-android/patches/bionic-compat.js"
-echo -e "${GREEN}[OK]${NC} bionic-compat.js installed"
-
-cp "$SCRIPT_DIR/patches/termux-compat.h" "$HOME/.openclaw-android/patches/termux-compat.h"
-echo -e "${GREEN}[OK]${NC} termux-compat.h installed"
-
-# Install spawn.h stub if missing (needed for koffi/native module builds)
-if [ ! -f "$PREFIX/include/spawn.h" ]; then
- cp "$SCRIPT_DIR/patches/spawn.h" "$PREFIX/include/spawn.h"
- echo -e "${GREEN}[OK]${NC} spawn.h stub installed"
-else
- echo -e "${GREEN}[OK]${NC} spawn.h already exists"
+export OA_GLIBC=1
+
+step 6 "Platform Package Install (L2)"
+bash "$SCRIPT_DIR/platforms/$SELECTED_PLATFORM/install.sh"
+
+echo ""
+echo -e "${BOLD}[6.5] Environment Variables + CLI + Marker${NC}"
+echo "----------------------------------------"
+bash "$SCRIPT_DIR/scripts/setup-env.sh"
+
+PLATFORM_ENV_SCRIPT="$SCRIPT_DIR/platforms/$SELECTED_PLATFORM/env.sh"
+if [ -f "$PLATFORM_ENV_SCRIPT" ]; then
+ eval "$(bash "$PLATFORM_ENV_SCRIPT")"
fi
-# Install oaupdate command (update.sh wrapper → $PREFIX/bin/oaupdate)
+mkdir -p "$PROJECT_DIR"
+echo "$SELECTED_PLATFORM" > "$PLATFORM_MARKER"
+
+cp "$SCRIPT_DIR/oa.sh" "$PREFIX/bin/oa"
+chmod +x "$PREFIX/bin/oa"
cp "$SCRIPT_DIR/update.sh" "$PREFIX/bin/oaupdate"
chmod +x "$PREFIX/bin/oaupdate"
-echo -e "${GREEN}[OK]${NC} oaupdate command installed"
-echo ""
-echo "Running: npm install -g openclaw@latest"
-echo "This may take several minutes..."
-echo ""
+cp "$SCRIPT_DIR/uninstall.sh" "$PROJECT_DIR/uninstall.sh"
+chmod +x "$PROJECT_DIR/uninstall.sh"
-npm install -g openclaw@latest
+mkdir -p "$PROJECT_DIR/scripts"
+mkdir -p "$PROJECT_DIR/platforms"
+cp "$SCRIPT_DIR/scripts/lib.sh" "$PROJECT_DIR/scripts/lib.sh"
+cp "$SCRIPT_DIR/scripts/setup-env.sh" "$PROJECT_DIR/scripts/setup-env.sh"
+rm -rf "$PROJECT_DIR/platforms/$SELECTED_PLATFORM"
+cp -R "$SCRIPT_DIR/platforms/$SELECTED_PLATFORM" "$PROJECT_DIR/platforms/$SELECTED_PLATFORM"
-echo ""
-echo -e "${GREEN}[OK]${NC} OpenClaw installed"
+step 7 "Install Optional Tools (L3)"
+[ "$INSTALL_TMUX" = true ] && pkg install -y tmux || true
+[ "$INSTALL_TTYD" = true ] && pkg install -y ttyd || true
+[ "$INSTALL_DUFS" = true ] && pkg install -y dufs || true
+[ "$INSTALL_ANDROID_TOOLS" = true ] && pkg install -y android-tools || true
-# Apply path patches to installed modules
-echo ""
-bash "$SCRIPT_DIR/patches/apply-patches.sh"
+[ "$INSTALL_CHROMIUM" = true ] && bash "$SCRIPT_DIR/scripts/install-chromium.sh" install || true
-# Build sharp for image processing (non-critical)
-echo ""
-bash "$SCRIPT_DIR/scripts/build-sharp.sh"
+[ "$INSTALL_CODE_SERVER" = true ] && mkdir -p "$PROJECT_DIR/patches" && cp "$SCRIPT_DIR/patches/argon2-stub.js" "$PROJECT_DIR/patches/argon2-stub.js" && bash "$SCRIPT_DIR/scripts/install-code-server.sh" install || true
-# ─────────────────────────────────────────────
-step 6 "Verifying Installation"
-bash "$SCRIPT_DIR/tests/verify-install.sh"
+[ "$INSTALL_OPENCODE" = true ] && bash "$SCRIPT_DIR/scripts/install-opencode.sh" install || true
-# ─────────────────────────────────────────────
-step 7 "Updating OpenClaw"
-echo "Running: openclaw update"
-echo ""
-openclaw update || true
+[ "$INSTALL_CLAUDE_CODE" = true ] && npm install -g @anthropic-ai/claude-code || true
+[ "$INSTALL_GEMINI_CLI" = true ] && npm install -g @google/gemini-cli || true
+[ "$INSTALL_CODEX_CLI" = true ] && npm install -g @openai/codex || true
+
+step 8 "Verification"
+bash "$SCRIPT_DIR/tests/verify-install.sh"
echo ""
echo -e "${BOLD}========================================${NC}"
echo -e "${GREEN}${BOLD} Installation Complete!${NC}"
echo -e "${BOLD}========================================${NC}"
echo ""
-echo -e " OpenClaw $(openclaw --version)"
+echo -e " $PLATFORM_NAME $($PLATFORM_VERSION_CMD 2>/dev/null || echo '')"
echo ""
echo "Next step:"
-echo " Run 'openclaw onboard' to start setup."
-echo ""
-echo "To update: oaupdate && source ~/.bashrc"
-echo "To uninstall: bash ~/.openclaw-android/uninstall.sh"
+echo " $PLATFORM_POST_INSTALL_MSG"
echo ""
diff --git a/oa.sh b/oa.sh
new file mode 100644
index 0000000..9e959ae
--- /dev/null
+++ b/oa.sh
@@ -0,0 +1,195 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_DIR="$HOME/.openclaw-android"
+
+if [ -f "$HOME/.openclaw-android/scripts/lib.sh" ]; then
+ # shellcheck source=/dev/null
+ source "$HOME/.openclaw-android/scripts/lib.sh"
+else
+ OA_VERSION="1.0.6"
+ RED='\033[0;31m'
+ GREEN='\033[0;32m'
+ YELLOW='\033[1;33m'
+ BOLD='\033[1m'
+ NC='\033[0m'
+ REPO_BASE="https://raw.githubusercontent.com/AidanPark/openclaw-android/main"
+ PLATFORM_MARKER="$PROJECT_DIR/.platform"
+
+ detect_platform() {
+ if [ -f "$PLATFORM_MARKER" ]; then
+ cat "$PLATFORM_MARKER"
+ return 0
+ fi
+ return 1
+ }
+fi
+
+show_help() {
+ echo ""
+ echo -e "${BOLD}oa${NC} — OpenClaw on Android CLI v${OA_VERSION}"
+ echo ""
+ echo "Usage: oa [option]"
+ echo ""
+ echo "Options:"
+ echo " --update Update OpenClaw and Android patches"
+ echo " --install Install optional tools (tmux, code-server, AI CLIs, etc.)"
+ echo " --uninstall Remove OpenClaw on Android"
+ echo " --status Show installation status and all components"
+ echo " --version, -v Show version"
+ echo " --help, -h Show this help message"
+ echo ""
+}
+
+show_version() {
+ echo "oa v${OA_VERSION} (OpenClaw on Android)"
+
+ local latest
+ latest=$(curl -sfL --max-time 3 "$REPO_BASE/scripts/lib.sh" 2>/dev/null \
+ | grep -m1 '^OA_VERSION=' | cut -d'"' -f2) || true
+
+ if [ -n "${latest:-}" ]; then
+ if [ "$latest" = "$OA_VERSION" ]; then
+ echo -e " ${GREEN}Up to date${NC}"
+ else
+ echo -e " ${YELLOW}v${latest} available${NC} - run: oa --update"
+ fi
+ fi
+}
+
+cmd_update() {
+ if ! command -v curl &>/dev/null; then
+ echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl"
+ exit 1
+ fi
+
+ mkdir -p "$PROJECT_DIR"
+ local LOGFILE="$PROJECT_DIR/update.log"
+
+ local TMPFILE
+ TMPFILE=$(mktemp "${PREFIX:-/tmp}/tmp/update-core.XXXXXX.sh" 2>/dev/null) \
+ || TMPFILE=$(mktemp /tmp/update-core.XXXXXX.sh)
+
+ if ! curl -sfL "$REPO_BASE/update-core.sh" -o "$TMPFILE"; then
+ rm -f "$TMPFILE"
+ echo -e "${RED}[FAIL]${NC} Failed to download update-core.sh"
+ exit 1
+ fi
+
+ bash "$TMPFILE" 2>&1 | tee "$LOGFILE"
+ rm -f "$TMPFILE"
+
+ echo ""
+ echo -e "${YELLOW}Log saved to $LOGFILE${NC}"
+}
+
+cmd_uninstall() {
+ local UNINSTALL_SCRIPT="$PROJECT_DIR/uninstall.sh"
+
+ if [ ! -f "$UNINSTALL_SCRIPT" ]; then
+ echo -e "${RED}[FAIL]${NC} Uninstall script not found at $UNINSTALL_SCRIPT"
+ echo ""
+ echo "You can download it manually:"
+ echo " curl -sL $REPO_BASE/uninstall.sh -o $UNINSTALL_SCRIPT && chmod +x $UNINSTALL_SCRIPT"
+ exit 1
+ fi
+
+ bash "$UNINSTALL_SCRIPT"
+}
+
+cmd_status() {
+ echo ""
+ echo -e "${BOLD}========================================${NC}"
+ echo -e "${BOLD} OpenClaw on Android — Status${NC}"
+ echo -e "${BOLD}========================================${NC}"
+
+ echo ""
+ echo -e "${BOLD}Version${NC}"
+ echo " oa: v${OA_VERSION}"
+
+ local PLATFORM
+ PLATFORM=$(detect_platform 2>/dev/null) || PLATFORM=""
+ if [ -n "$PLATFORM" ]; then
+ echo " Platform: $PLATFORM"
+ else
+ echo -e " Platform: ${RED}not detected${NC}"
+ fi
+
+ echo ""
+ echo -e "${BOLD}Environment${NC}"
+ echo " PREFIX: ${PREFIX:-not set}"
+ echo " TMPDIR: ${TMPDIR:-not set}"
+
+ echo ""
+ echo -e "${BOLD}Paths${NC}"
+ local CHECK_DIRS=("$PROJECT_DIR" "${PREFIX:-}/tmp")
+ for dir in "${CHECK_DIRS[@]}"; do
+ if [ -d "$dir" ]; then
+ echo -e " ${GREEN}[OK]${NC} $dir"
+ else
+ echo -e " ${RED}[MISS]${NC} $dir"
+ fi
+ done
+
+ echo ""
+ echo -e "${BOLD}Configuration${NC}"
+ if grep -qF "OpenClaw on Android" "$HOME/.bashrc" 2>/dev/null; then
+ echo -e " ${GREEN}[OK]${NC} .bashrc environment block present"
+ else
+ echo -e " ${RED}[MISS]${NC} .bashrc environment block not found"
+ fi
+
+ local STATUS_SCRIPT="$PROJECT_DIR/platforms/$PLATFORM/status.sh"
+ if [ -n "$PLATFORM" ] && [ -f "$STATUS_SCRIPT" ]; then
+ bash "$STATUS_SCRIPT"
+ fi
+
+ echo ""
+}
+
+cmd_install() {
+ if ! command -v curl &>/dev/null; then
+ echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl"
+ exit 1
+ fi
+
+ local TMPFILE
+ TMPFILE=$(mktemp "${PREFIX:-/tmp}/tmp/install-tools.XXXXXX.sh" 2>/dev/null) \
+ || TMPFILE=$(mktemp /tmp/install-tools.XXXXXX.sh)
+
+ if ! curl -sfL "$REPO_BASE/install-tools.sh" -o "$TMPFILE"; then
+ rm -f "$TMPFILE"
+ echo -e "${RED}[FAIL]${NC} Failed to download install-tools.sh"
+ exit 1
+ fi
+
+ bash "$TMPFILE"
+ rm -f "$TMPFILE"
+}
+
+case "${1:-}" in
+ --update)
+ cmd_update
+ ;;
+ --install)
+ cmd_install
+ ;;
+ --uninstall)
+ cmd_uninstall
+ ;;
+ --status)
+ cmd_status
+ ;;
+ --version|-v)
+ show_version
+ ;;
+ --help|-h|"")
+ show_help
+ ;;
+ *)
+ echo -e "${RED}Unknown option: $1${NC}"
+ echo ""
+ show_help
+ exit 1
+ ;;
+esac
diff --git a/patches/apply-patches.sh b/patches/apply-patches.sh
index ef5eb9c..50d5b29 100755
--- a/patches/apply-patches.sh
+++ b/patches/apply-patches.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# apply-patches.sh - Apply all patches for OpenClaw on Termux
+# apply-patches.sh - Apply all patches for OpenClaw on Termux (glibc architecture)
set -euo pipefail
GREEN='\033[0;32m'
@@ -19,14 +19,14 @@ mkdir -p "$PATCH_DEST"
# Start logging
echo "Patch application started: $(date)" > "$LOG_FILE"
-# 1. Copy bionic-compat.js
-if [ -f "$SCRIPT_DIR/bionic-compat.js" ]; then
- cp "$SCRIPT_DIR/bionic-compat.js" "$PATCH_DEST/bionic-compat.js"
- echo -e "${GREEN}[OK]${NC} Copied bionic-compat.js to $PATCH_DEST/"
- echo " Copied bionic-compat.js" >> "$LOG_FILE"
+# 1. Copy glibc-compat.js (replaces bionic-compat.js in glibc architecture)
+if [ -f "$SCRIPT_DIR/glibc-compat.js" ]; then
+ cp "$SCRIPT_DIR/glibc-compat.js" "$PATCH_DEST/glibc-compat.js"
+ echo -e "${GREEN}[OK]${NC} Copied glibc-compat.js to $PATCH_DEST/"
+ echo " Copied glibc-compat.js" >> "$LOG_FILE"
else
- echo -e "${RED}[FAIL]${NC} bionic-compat.js not found in $SCRIPT_DIR"
- echo " FAILED: bionic-compat.js not found" >> "$LOG_FILE"
+ echo -e "${RED}[FAIL]${NC} glibc-compat.js not found in $SCRIPT_DIR"
+ echo " FAILED: glibc-compat.js not found" >> "$LOG_FILE"
exit 1
fi
diff --git a/patches/argon2-stub.js b/patches/argon2-stub.js
new file mode 100644
index 0000000..0706910
--- /dev/null
+++ b/patches/argon2-stub.js
@@ -0,0 +1,23 @@
+// argon2-stub.js - JS stub replacing argon2 native module for Termux
+// The native argon2 module requires glibc and cannot run on Termux (Bionic libc).
+// Since code-server is started with --auth none, argon2 is never actually called.
+// This stub satisfies the require() without loading native code.
+
+"use strict";
+
+module.exports.hash = async function hash() {
+ throw new Error("argon2 native module is not available on Termux. Use --auth none.");
+};
+
+module.exports.verify = async function verify() {
+ throw new Error("argon2 native module is not available on Termux. Use --auth none.");
+};
+
+module.exports.needsRehash = function needsRehash() {
+ return false;
+};
+
+// Argon2 type constants (for compatibility)
+module.exports.argon2d = 0;
+module.exports.argon2i = 1;
+module.exports.argon2id = 2;
diff --git a/patches/bionic-compat.js b/patches/bionic-compat.js
deleted file mode 100644
index ef3f6e3..0000000
--- a/patches/bionic-compat.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * bionic-compat.js - Android Bionic libc compatibility shim
- *
- * Loaded via NODE_OPTIONS="-r /bionic-compat.js"
- *
- * Patches:
- * - os.networkInterfaces(): try-catch wrapper for Bionic getifaddrs() crashes
- * - os.cpus(): fallback for empty array (Android /proc/cpuinfo restriction)
- */
-
-'use strict';
-
-// Override process.platform from 'android' to 'linux'
-// Termux runs on Linux kernel but Node.js reports 'android',
-// causing OpenClaw to reject the platform as unsupported.
-Object.defineProperty(process, 'platform', {
- value: 'linux',
- writable: false,
- enumerable: true,
- configurable: true,
-});
-
-const os = require('os');
-
-// os.cpus() returns empty array on some Android devices,
-// causing tools that use os.cpus().length for parallelism to fail (e.g. make -j0)
-const _originalCpus = os.cpus;
-
-os.cpus = function cpus() {
- const result = _originalCpus.call(os);
- if (result.length > 0) {
- return result;
- }
- return [{ model: 'unknown', speed: 0, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } }];
-};
-
-const _originalNetworkInterfaces = os.networkInterfaces;
-
-os.networkInterfaces = function networkInterfaces() {
- try {
- return _originalNetworkInterfaces.call(os);
- } catch {
- return {
- lo: [
- {
- address: '127.0.0.1',
- netmask: '255.0.0.0',
- family: 'IPv4',
- mac: '00:00:00:00:00:00',
- internal: true,
- cidr: '127.0.0.1/8',
- },
- ],
- };
- }
-};
diff --git a/patches/glibc-compat.js b/patches/glibc-compat.js
new file mode 100644
index 0000000..51db5ea
--- /dev/null
+++ b/patches/glibc-compat.js
@@ -0,0 +1,127 @@
+/**
+ * glibc-compat.js - Minimal compatibility shim for glibc Node.js on Android
+ *
+ * This is the successor to bionic-compat.js, drastically reduced for glibc.
+ *
+ * What's NOT needed anymore (glibc handles these):
+ * - process.platform override (glibc Node.js reports 'linux' natively)
+ * - renameat2 / spawn.h stubs (glibc includes them)
+ * - CXXFLAGS / GYP_DEFINES overrides (glibc is standard Linux)
+ *
+ * What's still needed (kernel/Android-level restrictions, not libc):
+ * - os.cpus() fallback: SELinux blocks /proc/stat on Android 8+
+ * - os.networkInterfaces() safety: EACCES on some Android configurations
+ * - /bin/sh path shim: Android 7-8 lacks /bin/sh (Android 9+ has it)
+ *
+ * Loaded via node wrapper script: node --require /glibc-compat.js
+ */
+
+'use strict';
+
+const os = require('os');
+const fs = require('fs');
+const path = require('path');
+
+// ─── process.execPath fix ────────────────────────────────────
+// When node runs via grun (ld.so node.real), process.execPath points to
+// ld.so instead of the node wrapper. Apps that spawn child node processes
+// using process.execPath (e.g., openclaw) will call ld.so directly,
+// bypassing the wrapper's LD_PRELOAD unset and compat loading.
+// Fix: point process.execPath to the wrapper script.
+
+const _wrapperPath = path.join(
+ process.env.HOME || '/data/data/com.termux/files/home',
+ '.openclaw-android', 'node', 'bin', 'node'
+);
+try {
+ if (fs.existsSync(_wrapperPath)) {
+ Object.defineProperty(process, 'execPath', {
+ value: _wrapperPath,
+ writable: true,
+ configurable: true,
+ });
+ }
+} catch {}
+
+
+// ─── os.cpus() fallback ─────────────────────────────────────
+// Android 8+ (API 26+) blocks /proc/stat via SELinux + hidepid=2.
+// libuv reads /proc/stat for CPU info → returns empty array.
+// Tools using os.cpus().length for parallelism (e.g., make -j) break with 0.
+
+const _originalCpus = os.cpus;
+
+os.cpus = function cpus() {
+ const result = _originalCpus.call(os);
+ if (result.length > 0) {
+ return result;
+ }
+ // Return a single fake CPU entry so .length is at least 1
+ return [{ model: 'unknown', speed: 0, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } }];
+};
+
+// ─── os.networkInterfaces() safety ──────────────────────────
+// Some Android configurations throw EACCES when reading network
+// interface information. Wrap with try-catch to prevent crashes.
+
+const _originalNetworkInterfaces = os.networkInterfaces;
+
+os.networkInterfaces = function networkInterfaces() {
+ try {
+ return _originalNetworkInterfaces.call(os);
+ } catch {
+ // Return minimal loopback interface
+ return {
+ lo: [
+ {
+ address: '127.0.0.1',
+ netmask: '255.0.0.0',
+ family: 'IPv4',
+ mac: '00:00:00:00:00:00',
+ internal: true,
+ cidr: '127.0.0.1/8',
+ },
+ ],
+ };
+ }
+};
+
+// ─── /bin/sh path shim (Android 7-8 only) ───────────────────
+// Android 9+ (API 28+) has /bin → /system/bin symlink, so /bin/sh exists.
+// Android 7-8 lacks /bin/sh entirely.
+// Node.js child_process hardcodes /bin/sh as the default shell on Linux.
+// With glibc (platform='linux'), LD_PRELOAD is unset, so libtermux-exec.so
+// path translation is not available.
+//
+// This shim only activates if /bin/sh doesn't exist.
+
+if (!fs.existsSync('/bin/sh')) {
+ const child_process = require('child_process');
+ const termuxSh = (process.env.PREFIX || '/data/data/com.termux/files/usr') + '/bin/sh';
+
+ if (fs.existsSync(termuxSh)) {
+ // Override exec/execSync to use Termux shell
+ const _originalExec = child_process.exec;
+ const _originalExecSync = child_process.execSync;
+
+ child_process.exec = function exec(command, options, callback) {
+ if (typeof options === 'function') {
+ callback = options;
+ options = {};
+ }
+ options = options || {};
+ if (!options.shell) {
+ options.shell = termuxSh;
+ }
+ return _originalExec.call(child_process, command, options, callback);
+ };
+
+ child_process.execSync = function execSync(command, options) {
+ options = options || {};
+ if (!options.shell) {
+ options.shell = termuxSh;
+ }
+ return _originalExecSync.call(child_process, command, options);
+ };
+ }
+}
diff --git a/platforms/openclaw/config.env b/platforms/openclaw/config.env
new file mode 100644
index 0000000..c1258a3
--- /dev/null
+++ b/platforms/openclaw/config.env
@@ -0,0 +1,23 @@
+# config.env — OpenClaw platform metadata and dependency declarations
+# Sourced by orchestrators via load_platform_config()
+
+# ── Platform info ──
+PLATFORM_NAME="OpenClaw"
+PLATFORM_BINARY="openclaw"
+PLATFORM_DATA_DIR="$HOME/.openclaw"
+PLATFORM_VERSION_CMD="openclaw --version"
+PLATFORM_START_CMD="openclaw gateway"
+PLATFORM_POST_INSTALL_MSG="Run 'openclaw onboard' to start setup."
+
+# ── Dependency declarations ── orchestrator reads these for conditional install
+PLATFORM_NEEDS_GLIBC=true
+PLATFORM_NEEDS_NODEJS=true
+PLATFORM_NEEDS_BUILD_TOOLS=true
+PLATFORM_NEEDS_PYTHON=false
+PLATFORM_NEEDS_PROOT=false
+
+# ── Install method ──
+PLATFORM_INSTALL_METHOD="npm"
+PLATFORM_NPM_PACKAGE="openclaw"
+PLATFORM_GITHUB_REPO=""
+PLATFORM_PIP_PACKAGE=""
diff --git a/platforms/openclaw/env.sh b/platforms/openclaw/env.sh
new file mode 100755
index 0000000..c6f5f86
--- /dev/null
+++ b/platforms/openclaw/env.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# env.sh — OpenClaw platform environment variables
+# Called by setup-env.sh; stdout is inserted into .bashrc block.
+# Uses single-quoted heredoc to prevent variable expansion at install time
+# (variables must expand at shell load time).
+
+cat << 'EOF'
+export CONTAINER=1
+export CLAWDHUB_WORKDIR="$HOME/.openclaw/workspace"
+export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include"
+EOF
diff --git a/platforms/openclaw/install.sh b/platforms/openclaw/install.sh
new file mode 100755
index 0000000..50b73b2
--- /dev/null
+++ b/platforms/openclaw/install.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/scripts/lib.sh"
+
+echo "=== Installing OpenClaw Platform Package ==="
+echo ""
+
+export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include"
+
+python -c "import yaml" 2>/dev/null || pip install pyyaml -q || true
+
+mkdir -p "$PROJECT_DIR/patches"
+cp "$SCRIPT_DIR/../../patches/glibc-compat.js" "$PROJECT_DIR/patches/glibc-compat.js"
+
+cp "$SCRIPT_DIR/../../patches/systemctl" "$PREFIX/bin/systemctl"
+chmod +x "$PREFIX/bin/systemctl"
+
+# Clean up existing installation for smooth reinstall
+if npm list -g openclaw &>/dev/null 2>&1 || [ -d "$PREFIX/lib/node_modules/openclaw" ]; then
+ echo "Existing installation detected \u2014 cleaning up for reinstall..."
+ npm uninstall -g openclaw 2>/dev/null || true
+ rm -rf "$PREFIX/lib/node_modules/openclaw" 2>/dev/null || true
+ npm uninstall -g clawdhub 2>/dev/null || true
+ rm -rf "$PREFIX/lib/node_modules/clawdhub" 2>/dev/null || true
+ rm -rf "$HOME/.npm/_cacache" 2>/dev/null || true
+ echo -e "${GREEN}[OK]${NC} Previous installation cleaned"
+fi
+
+echo "Running: npm install -g openclaw@latest --ignore-scripts"
+echo "This may take several minutes..."
+echo ""
+npm install -g openclaw@latest --ignore-scripts
+
+echo ""
+echo -e "${GREEN}[OK]${NC} OpenClaw installed"
+
+bash "$SCRIPT_DIR/patches/openclaw-apply-patches.sh"
+
+echo ""
+echo "Installing clawdhub (skill manager)..."
+if npm install -g clawdhub --no-fund --no-audit; then
+ echo -e "${GREEN}[OK]${NC} clawdhub installed"
+ CLAWHUB_DIR="$(npm root -g)/clawdhub"
+ if [ -d "$CLAWHUB_DIR" ] && ! (cd "$CLAWHUB_DIR" && node -e "require('undici')" 2>/dev/null); then
+ echo "Installing undici dependency for clawdhub..."
+ if (cd "$CLAWHUB_DIR" && npm install undici --no-fund --no-audit); then
+ echo -e "${GREEN}[OK]${NC} undici installed for clawdhub"
+ else
+ echo -e "${YELLOW}[WARN]${NC} undici installation failed (clawdhub may not work)"
+ fi
+ fi
+else
+ echo -e "${YELLOW}[WARN]${NC} clawdhub installation failed (non-critical)"
+ echo " Retry manually: npm i -g clawdhub"
+fi
+
+mkdir -p "$HOME/.openclaw"
+
+echo ""
+echo "Running: openclaw update"
+echo " (This includes building native modules and may take 5-10 minutes)"
+echo ""
+openclaw update || true
diff --git a/platforms/openclaw/patches/openclaw-apply-patches.sh b/platforms/openclaw/patches/openclaw-apply-patches.sh
new file mode 100755
index 0000000..7c25c72
--- /dev/null
+++ b/platforms/openclaw/patches/openclaw-apply-patches.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+NC='\033[0m'
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+LOG_FILE="$HOME/.openclaw-android/patch.log"
+
+echo "=== Applying OpenClaw Patches ==="
+echo ""
+
+mkdir -p "$(dirname "$LOG_FILE")"
+echo "Patch application started: $(date)" > "$LOG_FILE"
+
+if [ -f "$SCRIPT_DIR/openclaw-patch-paths.sh" ]; then
+ bash "$SCRIPT_DIR/openclaw-patch-paths.sh" 2>&1 | tee -a "$LOG_FILE"
+else
+ echo -e "${RED}[FAIL]${NC} openclaw-patch-paths.sh not found in $SCRIPT_DIR"
+ echo " FAILED: openclaw-patch-paths.sh not found" >> "$LOG_FILE"
+ exit 1
+fi
+
+echo ""
+echo "Patch log saved to: $LOG_FILE"
+echo -e "${GREEN}OpenClaw patches applied.${NC}"
+echo "Patch application completed: $(date)" >> "$LOG_FILE"
diff --git a/platforms/openclaw/patches/openclaw-build-sharp.sh b/platforms/openclaw/patches/openclaw-build-sharp.sh
new file mode 100755
index 0000000..56d5a1c
--- /dev/null
+++ b/platforms/openclaw/patches/openclaw-build-sharp.sh
@@ -0,0 +1,116 @@
+#!/usr/bin/env bash
+# build-sharp.sh - Enable sharp image processing on Android (Termux)
+#
+# Strategy:
+# 1. Check if sharp already works → skip
+# 2. Install WebAssembly fallback (@img/sharp-wasm32)
+# Native sharp binaries are built for glibc Linux. Android's Bionic libc
+# cannot dlopen glibc-linked .node addons, so the prebuilt linux-arm64
+# binding never loads. The WASM build uses Emscripten and runs entirely
+# in V8 — zero native dependencies.
+# 3. If WASM fails → attempt native rebuild as last resort
+set -euo pipefail
+
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m'
+
+echo "=== Building sharp (image processing) ==="
+echo ""
+
+# Ensure required environment variables are set (for standalone use)
+export TMPDIR="${TMPDIR:-$PREFIX/tmp}"
+export TMP="$TMPDIR"
+export TEMP="$TMPDIR"
+export CONTAINER="${CONTAINER:-1}"
+
+# Locate openclaw install directory
+OPENCLAW_DIR="$(npm root -g)/openclaw"
+
+if [ ! -d "$OPENCLAW_DIR" ]; then
+ echo -e "${RED}[FAIL]${NC} OpenClaw directory not found: $OPENCLAW_DIR"
+ exit 0
+fi
+
+# Skip rebuild if sharp is already working (e.g. WASM installed on prior run)
+if [ -d "$OPENCLAW_DIR/node_modules/sharp" ]; then
+ if node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then
+ echo -e "${GREEN}[OK]${NC} sharp is already working — skipping rebuild"
+ exit 0
+ fi
+fi
+
+# ── Strategy 1: WebAssembly fallback (recommended for Android) ──────────
+# sharp's JS loader tries these paths in order:
+# 1. ../src/build/Release/sharp-{platform}.node (source build)
+# 2. ../src/build/Release/sharp-wasm32.node (source build)
+# 3. @img/sharp-{platform}/sharp.node (prebuilt native)
+# 4. @img/sharp-wasm32/sharp.node (prebuilt WASM) ← this
+# By installing @img/sharp-wasm32, path 4 catches the fallback automatically.
+
+echo "Installing sharp WebAssembly runtime..."
+if (cd "$OPENCLAW_DIR" && npm install @img/sharp-wasm32 --force --no-audit --no-fund 2>&1 | tail -3); then
+ if node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then
+ echo ""
+ echo -e "${GREEN}[OK]${NC} sharp enabled via WebAssembly — image processing ready"
+ exit 0
+ else
+ echo -e "${YELLOW}[WARN]${NC} WASM package installed but sharp still not loading"
+ fi
+else
+ echo -e "${YELLOW}[WARN]${NC} Failed to install WASM package"
+fi
+
+# ── Strategy 2: Native rebuild (last resort) ────────────────────────────
+
+echo ""
+echo "Attempting native rebuild as fallback..."
+
+# Install required packages
+echo "Installing build dependencies..."
+if ! pkg install -y libvips binutils; then
+ echo -e "${YELLOW}[WARN]${NC} Failed to install build dependencies"
+ echo " Image processing will not be available, but OpenClaw will work normally."
+ exit 0
+fi
+echo -e "${GREEN}[OK]${NC} libvips and binutils installed"
+
+# Create ar symlink if missing (Termux provides llvm-ar but not ar)
+if [ ! -e "$PREFIX/bin/ar" ] && [ -x "$PREFIX/bin/llvm-ar" ]; then
+ ln -s "$PREFIX/bin/llvm-ar" "$PREFIX/bin/ar"
+ echo -e "${GREEN}[OK]${NC} Created ar → llvm-ar symlink"
+fi
+
+# Install node-gyp globally
+echo "Installing node-gyp..."
+if ! npm install -g node-gyp; then
+ echo -e "${YELLOW}[WARN]${NC} Failed to install node-gyp"
+ echo " Image processing will not be available, but OpenClaw will work normally."
+ exit 0
+fi
+echo -e "${GREEN}[OK]${NC} node-gyp installed"
+
+# Set build environment variables
+# On glibc architecture, these are handled by glibc's standard headers.
+# On Bionic (legacy), we need explicit compatibility flags.
+if [ ! -f "$HOME/.openclaw-android/.glibc-arch" ]; then
+ export CFLAGS="-Wno-error=implicit-function-declaration"
+ export CXXFLAGS="-include $HOME/.openclaw-android/patches/termux-compat.h"
+ export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX"
+fi
+export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include"
+
+echo "Rebuilding sharp in $OPENCLAW_DIR..."
+echo "This may take several minutes..."
+echo ""
+
+if (cd "$OPENCLAW_DIR" && npm rebuild sharp); then
+ echo ""
+ echo -e "${GREEN}[OK]${NC} sharp built successfully — image processing enabled"
+else
+ echo ""
+ echo -e "${YELLOW}[WARN]${NC} sharp could not be enabled (non-critical)"
+ echo " Image processing will not be available, but OpenClaw will work normally."
+ echo " You can retry later: bash ~/.openclaw-android/scripts/build-sharp.sh"
+fi
diff --git a/platforms/openclaw/patches/openclaw-patch-paths.sh b/platforms/openclaw/patches/openclaw-patch-paths.sh
new file mode 100755
index 0000000..96b4a1b
--- /dev/null
+++ b/platforms/openclaw/patches/openclaw-patch-paths.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+# patch-paths.sh - Patch hardcoded paths in installed OpenClaw modules
+set -euo pipefail
+
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m'
+
+echo "=== Patching Hardcoded Paths ==="
+echo ""
+
+# Ensure required environment variables are set (for standalone use)
+export TMPDIR="${TMPDIR:-$PREFIX/tmp}"
+
+# Find OpenClaw installation directory
+NPM_ROOT=$(npm root -g 2>/dev/null)
+OPENCLAW_DIR="$NPM_ROOT/openclaw"
+
+if [ ! -d "$OPENCLAW_DIR" ]; then
+ echo -e "${RED}[FAIL]${NC} OpenClaw not found at $OPENCLAW_DIR"
+ exit 1
+fi
+
+echo "OpenClaw found at: $OPENCLAW_DIR"
+
+PATCHED=0
+
+# Patch /tmp references to $PREFIX/tmp
+echo "Patching /tmp references..."
+TMP_FILES=$(grep -rl '/tmp' "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true)
+
+for f in $TMP_FILES; do
+ if [ -f "$f" ]; then
+ # Patch /tmp/ prefix paths (e.g. "/tmp/openclaw") — must run before exact match
+ sed -i "s|\"\/tmp/|\"$PREFIX/tmp/|g" "$f"
+ sed -i "s|'\/tmp/|'$PREFIX/tmp/|g" "$f"
+ sed -i "s|\`\/tmp/|\`$PREFIX/tmp/|g" "$f"
+ # Patch exact /tmp references (e.g. "/tmp")
+ sed -i "s|\"\/tmp\"|\"$PREFIX/tmp\"|g" "$f"
+ sed -i "s|'\/tmp'|'$PREFIX/tmp'|g" "$f"
+ echo -e " ${GREEN}[PATCHED]${NC} $f (tmp path)"
+ PATCHED=$((PATCHED + 1))
+ fi
+done
+
+# Patch /bin/sh references
+echo "Patching /bin/sh references..."
+SH_FILES=$(grep -rl '"/bin/sh"' "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true)
+SH_FILES2=$(grep -rl "'/bin/sh'" "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true)
+
+for f in $SH_FILES $SH_FILES2; do
+ if [ -f "$f" ]; then
+ sed -i "s|\"\/bin\/sh\"|\"$PREFIX/bin/sh\"|g" "$f"
+ sed -i "s|'\/bin\/sh'|'$PREFIX/bin/sh'|g" "$f"
+ echo -e " ${GREEN}[PATCHED]${NC} $f (bin/sh)"
+ PATCHED=$((PATCHED + 1))
+ fi
+done
+
+# Patch /bin/bash references
+echo "Patching /bin/bash references..."
+BASH_FILES=$(grep -rl '"/bin/bash"' "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true)
+BASH_FILES2=$(grep -rl "'/bin/bash'" "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true)
+
+for f in $BASH_FILES $BASH_FILES2; do
+ if [ -f "$f" ]; then
+ sed -i "s|\"\/bin\/bash\"|\"$PREFIX/bin/bash\"|g" "$f"
+ sed -i "s|'\/bin\/bash'|'$PREFIX/bin/bash'|g" "$f"
+ echo -e " ${GREEN}[PATCHED]${NC} $f (bin/bash)"
+ PATCHED=$((PATCHED + 1))
+ fi
+done
+
+# Patch /usr/bin/env references
+echo "Patching /usr/bin/env references..."
+ENV_FILES=$(grep -rl '"/usr/bin/env"' "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true)
+ENV_FILES2=$(grep -rl "'/usr/bin/env'" "$OPENCLAW_DIR" --include='*.js' --include='*.mjs' --include='*.cjs' 2>/dev/null || true)
+
+for f in $ENV_FILES $ENV_FILES2; do
+ if [ -f "$f" ]; then
+ sed -i "s|\"\/usr\/bin\/env\"|\"$PREFIX/bin/env\"|g" "$f"
+ sed -i "s|'\/usr\/bin\/env'|'$PREFIX/bin/env'|g" "$f"
+ echo -e " ${GREEN}[PATCHED]${NC} $f (usr/bin/env)"
+ PATCHED=$((PATCHED + 1))
+ fi
+done
+
+echo ""
+if [ "$PATCHED" -eq 0 ]; then
+ echo -e "${YELLOW}[INFO]${NC} No hardcoded paths found to patch."
+else
+ echo -e "${GREEN}Patched $PATCHED file(s).${NC}"
+fi
diff --git a/platforms/openclaw/status.sh b/platforms/openclaw/status.sh
new file mode 100644
index 0000000..6c8e84d
--- /dev/null
+++ b/platforms/openclaw/status.sh
@@ -0,0 +1,144 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/../../scripts/lib.sh"
+
+echo ""
+echo -e "${BOLD}Platform Components${NC}"
+
+if command -v openclaw &>/dev/null; then
+ echo " OpenClaw: $(openclaw --version 2>/dev/null || echo 'error')"
+else
+ echo -e " OpenClaw: ${RED}not installed${NC}"
+fi
+
+if command -v node &>/dev/null; then
+ echo " Node.js: $(node -v 2>/dev/null)"
+else
+ echo -e " Node.js: ${RED}not installed${NC}"
+fi
+
+if command -v npm &>/dev/null; then
+ echo " npm: $(npm -v 2>/dev/null)"
+else
+ echo -e " npm: ${RED}not installed${NC}"
+fi
+
+if command -v clawdhub &>/dev/null; then
+ echo " clawdhub: $(clawdhub --version 2>/dev/null || echo 'installed')"
+else
+ echo -e " clawdhub: ${YELLOW}not installed${NC}"
+fi
+
+if command -v code-server &>/dev/null; then
+ cs_ver=$(code-server --version 2>/dev/null || true)
+ cs_ver="${cs_ver%%$'\n'*}"
+ cs_status="stopped"
+ if pgrep -f "code-server" &>/dev/null; then
+ cs_status="running"
+ fi
+ echo " code-server: ${cs_ver:-installed} ($cs_status)"
+else
+ echo -e " code-server: ${YELLOW}not installed${NC}"
+fi
+
+if command -v opencode &>/dev/null; then
+ oc_status="stopped"
+ if pgrep -f "ld.so.opencode" &>/dev/null; then
+ oc_status="running"
+ fi
+ echo " OpenCode: $(opencode --version 2>/dev/null || echo 'installed') ($oc_status)"
+else
+ echo -e " OpenCode: ${YELLOW}not installed${NC}"
+fi
+
+if command -v chromium-browser &>/dev/null || command -v chromium &>/dev/null; then
+ cr_bin=$(command -v chromium-browser 2>/dev/null || command -v chromium 2>/dev/null)
+ cr_ver=$($cr_bin --version 2>/dev/null | head -1 || echo 'installed')
+ echo " Chromium: $cr_ver"
+else
+ echo -e " Chromium: ${YELLOW}not installed${NC}"
+fi
+
+echo ""
+echo -e "${BOLD}Architecture${NC}"
+if [ -f "$PROJECT_DIR/.glibc-arch" ]; then
+ echo -e " ${GREEN}[OK]${NC} glibc (v1.0.0+)"
+else
+ echo -e " ${YELLOW}[OLD]${NC} Bionic (pre-1.0.0) - run 'oa --update' to migrate"
+fi
+
+if [ "${OA_GLIBC:-}" = "1" ]; then
+ echo -e " ${GREEN}[OK]${NC} OA_GLIBC=1 (environment)"
+else
+ echo -e " ${YELLOW}[MISS]${NC} OA_GLIBC not set - run 'source ~/.bashrc'"
+fi
+
+echo ""
+echo -e "${BOLD}glibc Components${NC}"
+GLIBC_FILES=(
+ "$PROJECT_DIR/patches/glibc-compat.js"
+ "$PROJECT_DIR/.glibc-arch"
+ "${PREFIX:-}/glibc/lib/ld-linux-aarch64.so.1"
+)
+for file in "${GLIBC_FILES[@]}"; do
+ if [ -f "$file" ]; then
+ echo -e " ${GREEN}[OK]${NC} $(basename "$file")"
+ else
+ echo -e " ${RED}[MISS]${NC} $(basename "$file")"
+ fi
+done
+
+NODE_WRAPPER="$PROJECT_DIR/node/bin/node"
+if [ -f "$NODE_WRAPPER" ] && grep -q "bash" "$NODE_WRAPPER"; then
+ echo -e " ${GREEN}[OK]${NC} glibc node wrapper"
+else
+ echo -e " ${RED}[MISS]${NC} glibc node wrapper"
+fi
+
+if [ -f "${PREFIX:-}/bin/opencode" ]; then
+ echo -e " ${GREEN}[OK]${NC} opencode command"
+else
+ echo -e " ${YELLOW}[MISS]${NC} opencode command"
+fi
+
+
+echo ""
+echo -e "${BOLD}AI CLI Tools${NC}"
+for tool in "claude:Claude Code" "gemini:Gemini CLI" "codex:Codex CLI"; do
+ cmd="${tool%%:*}"
+ label="${tool##*:}"
+ if command -v "$cmd" &>/dev/null; then
+ version=$($cmd --version 2>/dev/null || echo "installed")
+ version="${version%%$'\n'*}"
+ echo -e " ${GREEN}[OK]${NC} $label: $version"
+ else
+ echo " [--] $label: not installed"
+ fi
+done
+
+echo ""
+echo -e "${BOLD}Skills${NC}"
+SKILLS_DIR="${CLAWDHUB_WORKDIR:-$HOME/.openclaw/workspace}/skills"
+if [ -d "$SKILLS_DIR" ]; then
+ count=$(find "$SKILLS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)
+ echo " Installed: $count"
+ echo " Path: $SKILLS_DIR"
+else
+ echo " No skills directory found"
+fi
+
+echo ""
+echo -e "${BOLD}Disk${NC}"
+if [ -d "$PROJECT_DIR" ]; then
+ echo " ~/.openclaw-android: $(du -sh "$PROJECT_DIR" 2>/dev/null | cut -f1)"
+fi
+if [ -d "$HOME/.openclaw" ]; then
+ echo " ~/.openclaw: $(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1)"
+fi
+if [ -d "$HOME/.bun" ]; then
+ echo " ~/.bun: $(du -sh "$HOME/.bun" 2>/dev/null | cut -f1)"
+fi
+AVAIL_MB=$(df "${PREFIX:-/}" 2>/dev/null | awk 'NR==2 {print int($4/1024)}') || true
+echo " Available: ${AVAIL_MB:-unknown}MB"
diff --git a/platforms/openclaw/uninstall.sh b/platforms/openclaw/uninstall.sh
new file mode 100644
index 0000000..82210c7
--- /dev/null
+++ b/platforms/openclaw/uninstall.sh
@@ -0,0 +1,125 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/../../scripts/lib.sh"
+
+echo "=== Removing OpenClaw Platform ==="
+echo ""
+
+step() {
+ echo ""
+ echo -e "${BOLD}[$1/7] $2${NC}"
+ echo "----------------------------------------"
+}
+
+step 1 "OpenClaw npm package"
+if command -v npm &>/dev/null; then
+ if npm list -g openclaw &>/dev/null; then
+ npm uninstall -g openclaw
+ echo -e "${GREEN}[OK]${NC} openclaw package removed"
+ else
+ echo -e "${YELLOW}[SKIP]${NC} openclaw not installed"
+ fi
+else
+ echo -e "${YELLOW}[SKIP]${NC} npm not found"
+fi
+
+step 2 "clawdhub npm package"
+if command -v npm &>/dev/null; then
+ if npm list -g clawdhub &>/dev/null; then
+ npm uninstall -g clawdhub
+ echo -e "${GREEN}[OK]${NC} clawdhub package removed"
+ else
+ echo -e "${YELLOW}[SKIP]${NC} clawdhub not installed"
+ fi
+else
+ echo -e "${YELLOW}[SKIP]${NC} npm not found"
+fi
+
+step 3 "OpenCode"
+OPENCODE_INSTALLED=false
+
+if [ "$OPENCODE_INSTALLED" = true ]; then
+ if ask_yn "Remove OpenCode (AI coding assistant)?"; then
+ if pgrep -f "ld.so.opencode" &>/dev/null; then
+ pkill -f "ld.so.opencode" || true
+ echo -e "${GREEN}[OK]${NC} Stopped running OpenCode"
+ fi
+ [ -f "$PREFIX/tmp/ld.so.opencode" ] && rm -f "$PREFIX/tmp/ld.so.opencode" && echo -e "${GREEN}[OK]${NC} Removed ld.so.opencode"
+ [ -f "$PREFIX/bin/opencode" ] && rm -f "$PREFIX/bin/opencode" && echo -e "${GREEN}[OK]${NC} Removed opencode wrapper"
+ [ -d "$HOME/.config/opencode" ] && rm -rf "$HOME/.config/opencode" && echo -e "${GREEN}[OK]${NC} Removed ~/.config/opencode"
+ else
+ echo -e "${YELLOW}[KEEP]${NC} Keeping OpenCode"
+ fi
+fi
+
+step 4 "Bun cleanup"
+if [ ! -f "$PREFIX/bin/opencode" ] && [ -d "$HOME/.bun" ]; then
+ rm -rf "$HOME/.bun"
+ echo -e "${GREEN}[OK]${NC} Removed ~/.bun"
+else
+ echo -e "${YELLOW}[SKIP]${NC} Bun is still required or not installed"
+fi
+
+step 5 "OpenClaw temporary files"
+if [ -d "${PREFIX:-}/tmp/openclaw" ]; then
+ rm -rf "${PREFIX:-}/tmp/openclaw"
+ echo -e "${GREEN}[OK]${NC} Removed ${PREFIX:-}/tmp/openclaw"
+else
+ echo -e "${YELLOW}[SKIP]${NC} ${PREFIX:-}/tmp/openclaw not found"
+fi
+
+step 6 "OpenClaw data"
+if [ -d "$HOME/.openclaw" ]; then
+ reply=""
+ read -rp "Remove OpenClaw data directory (~/.openclaw)? [y/N] " reply < /dev/tty
+ if [[ "$reply" =~ ^[Yy]$ ]]; then
+ rm -rf "$HOME/.openclaw"
+ echo -e "${GREEN}[OK]${NC} Removed ~/.openclaw"
+ else
+ echo -e "${YELLOW}[KEEP]${NC} Keeping ~/.openclaw"
+ fi
+else
+ echo -e "${YELLOW}[SKIP]${NC} ~/.openclaw not found"
+fi
+
+step 7 "AI CLI tools"
+AI_TOOLS_FOUND=()
+AI_TOOL_LABELS=()
+
+if command -v claude &>/dev/null; then
+ AI_TOOLS_FOUND+=("@anthropic-ai/claude-code")
+ AI_TOOL_LABELS+=("Claude Code")
+fi
+if command -v gemini &>/dev/null; then
+ AI_TOOLS_FOUND+=("@google/gemini-cli")
+ AI_TOOL_LABELS+=("Gemini CLI")
+fi
+if command -v codex &>/dev/null; then
+ AI_TOOLS_FOUND+=("@openai/codex")
+ AI_TOOL_LABELS+=("Codex CLI")
+fi
+
+if [ ${#AI_TOOLS_FOUND[@]} -eq 0 ]; then
+ echo -e "${YELLOW}[SKIP]${NC} No AI CLI tools detected"
+else
+ echo "Installed AI CLI tools detected:"
+ for label in "${AI_TOOL_LABELS[@]}"; do
+ echo " - $label"
+ done
+
+ reply=""
+ read -rp "Remove these AI CLI tools? [y/N] " reply < /dev/tty
+ if [[ "$reply" =~ ^[Yy]$ ]]; then
+ for pkg in "${AI_TOOLS_FOUND[@]}"; do
+ if npm uninstall -g "$pkg"; then
+ echo -e "${GREEN}[OK]${NC} Removed $pkg"
+ else
+ echo -e "${YELLOW}[WARN]${NC} Failed to remove $pkg"
+ fi
+ done
+ else
+ echo -e "${YELLOW}[KEEP]${NC} Keeping AI CLI tools"
+ fi
+fi
diff --git a/platforms/openclaw/update.sh b/platforms/openclaw/update.sh
new file mode 100755
index 0000000..08f4347
--- /dev/null
+++ b/platforms/openclaw/update.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/../../scripts/lib.sh"
+
+export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include"
+
+echo "=== Updating OpenClaw Platform ==="
+echo ""
+
+pkg install -y libvips binutils 2>/dev/null || true
+if [ ! -e "$PREFIX/bin/ar" ] && [ -x "$PREFIX/bin/llvm-ar" ]; then
+ ln -s "$PREFIX/bin/llvm-ar" "$PREFIX/bin/ar"
+fi
+
+CURRENT_VER=$(npm list -g openclaw 2>/dev/null | grep 'openclaw@' | sed 's/.*openclaw@//' | tr -d '[:space:]')
+LATEST_VER=$(npm view openclaw version 2>/dev/null || echo "")
+OPENCLAW_UPDATED=false
+
+if [ -n "$CURRENT_VER" ] && [ -n "$LATEST_VER" ] && [ "$CURRENT_VER" = "$LATEST_VER" ]; then
+ echo -e "${GREEN}[OK]${NC} openclaw $CURRENT_VER is already the latest"
+else
+ echo "Updating openclaw npm package... ($CURRENT_VER → $LATEST_VER)"
+ echo " (This may take several minutes depending on network speed)"
+ if npm install -g openclaw@latest --no-fund --no-audit --ignore-scripts; then
+ echo -e "${GREEN}[OK]${NC} openclaw $LATEST_VER updated"
+ OPENCLAW_UPDATED=true
+ else
+ echo -e "${YELLOW}[WARN]${NC} Package update failed (non-critical)"
+ echo " Retry manually: npm install -g openclaw@latest"
+ fi
+fi
+
+bash "$SCRIPT_DIR/patches/openclaw-apply-patches.sh"
+
+if [ "$OPENCLAW_UPDATED" = true ]; then
+ bash "$SCRIPT_DIR/patches/openclaw-build-sharp.sh" || true
+else
+ echo -e "${GREEN}[SKIP]${NC} openclaw $CURRENT_VER unchanged \u2014 sharp rebuild not needed"
+fi
+
+if command -v clawdhub &>/dev/null; then
+ CLAWDHUB_CURRENT_VER=$(npm list -g clawdhub 2>/dev/null | grep 'clawdhub@' | sed 's/.*clawdhub@//' | tr -d '[:space:]')
+ CLAWDHUB_LATEST_VER=$(npm view clawdhub version 2>/dev/null || echo "")
+ if [ -n "$CLAWDHUB_CURRENT_VER" ] && [ -n "$CLAWDHUB_LATEST_VER" ] && [ "$CLAWDHUB_CURRENT_VER" = "$CLAWDHUB_LATEST_VER" ]; then
+ echo -e "${GREEN}[OK]${NC} clawdhub $CLAWDHUB_CURRENT_VER is already the latest"
+ elif [ -n "$CLAWDHUB_LATEST_VER" ]; then
+ echo "Updating clawdhub... ($CLAWDHUB_CURRENT_VER → $CLAWDHUB_LATEST_VER)"
+ if npm install -g clawdhub@latest --no-fund --no-audit; then
+ echo -e "${GREEN}[OK]${NC} clawdhub $CLAWDHUB_LATEST_VER updated"
+ else
+ echo -e "${YELLOW}[WARN]${NC} clawdhub update failed (non-critical)"
+ fi
+ else
+ echo -e "${YELLOW}[WARN]${NC} Could not check clawdhub latest version"
+ fi
+else
+ if ask_yn "clawdhub (skill manager) is not installed. Install it?"; then
+ echo "Installing clawdhub..."
+ if npm install -g clawdhub --no-fund --no-audit; then
+ echo -e "${GREEN}[OK]${NC} clawdhub installed"
+ else
+ echo -e "${YELLOW}[WARN]${NC} clawdhub installation failed (non-critical)"
+ fi
+ else
+ echo -e "${YELLOW}[SKIP]${NC} Skipping clawdhub"
+ fi
+fi
+
+CLAWHUB_DIR="$(npm root -g)/clawdhub"
+if [ -d "$CLAWHUB_DIR" ] && ! (cd "$CLAWHUB_DIR" && node -e "require('undici')" 2>/dev/null); then
+ echo "Installing undici dependency for clawdhub..."
+ if (cd "$CLAWHUB_DIR" && npm install undici --no-fund --no-audit); then
+ echo -e "${GREEN}[OK]${NC} undici installed for clawdhub"
+ else
+ echo -e "${YELLOW}[WARN]${NC} undici installation failed"
+ fi
+else
+ UNDICI_VER=$(cd "$CLAWHUB_DIR" && node -e "console.log(require('undici/package.json').version)" 2>/dev/null || echo "")
+ echo -e "${GREEN}[OK]${NC} undici ${UNDICI_VER:-available}"
+fi
+
+OLD_SKILLS_DIR="$HOME/skills"
+CORRECT_SKILLS_DIR="$HOME/.openclaw/workspace/skills"
+if [ -d "$OLD_SKILLS_DIR" ] && [ "$(ls -A "$OLD_SKILLS_DIR" 2>/dev/null)" ]; then
+ echo ""
+ echo "Migrating skills from ~/skills/ to ~/.openclaw/workspace/skills/..."
+ mkdir -p "$CORRECT_SKILLS_DIR"
+ for skill in "$OLD_SKILLS_DIR"/*/; do
+ [ -d "$skill" ] || continue
+ skill_name=$(basename "$skill")
+ if [ ! -d "$CORRECT_SKILLS_DIR/$skill_name" ]; then
+ if mv "$skill" "$CORRECT_SKILLS_DIR/$skill_name" 2>/dev/null; then
+ echo -e " ${GREEN}[OK]${NC} Migrated $skill_name"
+ else
+ echo -e " ${YELLOW}[WARN]${NC} Failed to migrate $skill_name"
+ fi
+ else
+ echo -e " ${YELLOW}[SKIP]${NC} $skill_name already exists in correct location"
+ fi
+ done
+ if rmdir "$OLD_SKILLS_DIR" 2>/dev/null; then
+ echo -e "${GREEN}[OK]${NC} Removed empty ~/skills/"
+ else
+ echo -e "${YELLOW}[WARN]${NC} ~/skills/ not empty after migration — check manually"
+ fi
+fi
+
+python -c "import yaml" 2>/dev/null || pip install pyyaml -q || true
diff --git a/platforms/openclaw/verify.sh b/platforms/openclaw/verify.sh
new file mode 100755
index 0000000..7f58576
--- /dev/null
+++ b/platforms/openclaw/verify.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/scripts/lib.sh"
+
+PASS=0
+FAIL=0
+WARN=0
+
+check_pass() {
+ echo -e "${GREEN}[PASS]${NC} $1"
+ PASS=$((PASS + 1))
+}
+
+check_fail() {
+ echo -e "${RED}[FAIL]${NC} $1"
+ FAIL=$((FAIL + 1))
+}
+
+check_warn() {
+ echo -e "${YELLOW}[WARN]${NC} $1"
+ WARN=$((WARN + 1))
+}
+
+echo "=== OpenClaw Platform Verification ==="
+echo ""
+
+if command -v openclaw &>/dev/null; then
+ CLAW_VER=$(openclaw --version 2>/dev/null || true)
+ if [ -n "$CLAW_VER" ]; then
+ check_pass "openclaw $CLAW_VER"
+ else
+ check_fail "openclaw found but --version failed"
+ fi
+else
+ check_fail "openclaw command not found"
+fi
+
+if [ "${CONTAINER:-}" = "1" ]; then
+ check_pass "CONTAINER=1"
+else
+ check_warn "CONTAINER is not set to 1"
+fi
+
+if command -v clawdhub &>/dev/null; then
+ check_pass "clawdhub command available"
+else
+ check_warn "clawdhub not found"
+fi
+
+if [ -d "$HOME/.openclaw" ]; then
+ check_pass "Directory $HOME/.openclaw exists"
+else
+ check_fail "Directory $HOME/.openclaw missing"
+fi
+
+echo ""
+echo "==============================="
+echo -e " Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}, ${YELLOW}$WARN warnings${NC}"
+echo "==============================="
+
+if [ "$FAIL" -gt 0 ]; then
+ exit 1
+fi
+exit 0
diff --git a/robots.txt b/robots.txt
deleted file mode 100644
index 4978962..0000000
--- a/robots.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-User-agent: *
-Allow: /
-
-Sitemap: https://myopenclawhub.com/sitemap.xml
diff --git a/scripts/build-sharp.sh b/scripts/build-sharp.sh
index 81d574a..56d5a1c 100755
--- a/scripts/build-sharp.sh
+++ b/scripts/build-sharp.sh
@@ -1,5 +1,14 @@
#!/usr/bin/env bash
-# build-sharp.sh - Build sharp native module for image processing support
+# build-sharp.sh - Enable sharp image processing on Android (Termux)
+#
+# Strategy:
+# 1. Check if sharp already works → skip
+# 2. Install WebAssembly fallback (@img/sharp-wasm32)
+# Native sharp binaries are built for glibc Linux. Android's Bionic libc
+# cannot dlopen glibc-linked .node addons, so the prebuilt linux-arm64
+# binding never loads. The WASM build uses Emscripten and runs entirely
+# in V8 — zero native dependencies.
+# 3. If WASM fails → attempt native rebuild as last resort
set -euo pipefail
GREEN='\033[0;32m'
@@ -15,7 +24,6 @@ export TMPDIR="${TMPDIR:-$PREFIX/tmp}"
export TMP="$TMPDIR"
export TEMP="$TMPDIR"
export CONTAINER="${CONTAINER:-1}"
-export NODE_OPTIONS="${NODE_OPTIONS:--r $HOME/.openclaw-android/patches/bionic-compat.js}"
# Locate openclaw install directory
OPENCLAW_DIR="$(npm root -g)/openclaw"
@@ -25,7 +33,7 @@ if [ ! -d "$OPENCLAW_DIR" ]; then
exit 0
fi
-# Skip rebuild if sharp is already working (e.g. compiled during npm install)
+# Skip rebuild if sharp is already working (e.g. WASM installed on prior run)
if [ -d "$OPENCLAW_DIR/node_modules/sharp" ]; then
if node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then
echo -e "${GREEN}[OK]${NC} sharp is already working — skipping rebuild"
@@ -33,6 +41,32 @@ if [ -d "$OPENCLAW_DIR/node_modules/sharp" ]; then
fi
fi
+# ── Strategy 1: WebAssembly fallback (recommended for Android) ──────────
+# sharp's JS loader tries these paths in order:
+# 1. ../src/build/Release/sharp-{platform}.node (source build)
+# 2. ../src/build/Release/sharp-wasm32.node (source build)
+# 3. @img/sharp-{platform}/sharp.node (prebuilt native)
+# 4. @img/sharp-wasm32/sharp.node (prebuilt WASM) ← this
+# By installing @img/sharp-wasm32, path 4 catches the fallback automatically.
+
+echo "Installing sharp WebAssembly runtime..."
+if (cd "$OPENCLAW_DIR" && npm install @img/sharp-wasm32 --force --no-audit --no-fund 2>&1 | tail -3); then
+ if node -e "require('$OPENCLAW_DIR/node_modules/sharp')" 2>/dev/null; then
+ echo ""
+ echo -e "${GREEN}[OK]${NC} sharp enabled via WebAssembly — image processing ready"
+ exit 0
+ else
+ echo -e "${YELLOW}[WARN]${NC} WASM package installed but sharp still not loading"
+ fi
+else
+ echo -e "${YELLOW}[WARN]${NC} Failed to install WASM package"
+fi
+
+# ── Strategy 2: Native rebuild (last resort) ────────────────────────────
+
+echo ""
+echo "Attempting native rebuild as fallback..."
+
# Install required packages
echo "Installing build dependencies..."
if ! pkg install -y libvips binutils; then
@@ -58,9 +92,13 @@ fi
echo -e "${GREEN}[OK]${NC} node-gyp installed"
# Set build environment variables
-export CFLAGS="-Wno-error=implicit-function-declaration"
-export CXXFLAGS="-include $HOME/.openclaw-android/patches/termux-compat.h"
-export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX"
+# On glibc architecture, these are handled by glibc's standard headers.
+# On Bionic (legacy), we need explicit compatibility flags.
+if [ ! -f "$HOME/.openclaw-android/.glibc-arch" ]; then
+ export CFLAGS="-Wno-error=implicit-function-declaration"
+ export CXXFLAGS="-include $HOME/.openclaw-android/patches/termux-compat.h"
+ export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX"
+fi
export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include"
echo "Rebuilding sharp in $OPENCLAW_DIR..."
@@ -72,7 +110,7 @@ if (cd "$OPENCLAW_DIR" && npm rebuild sharp); then
echo -e "${GREEN}[OK]${NC} sharp built successfully — image processing enabled"
else
echo ""
- echo -e "${YELLOW}[WARN]${NC} sharp build failed (non-critical)"
+ echo -e "${YELLOW}[WARN]${NC} sharp could not be enabled (non-critical)"
echo " Image processing will not be available, but OpenClaw will work normally."
echo " You can retry later: bash ~/.openclaw-android/scripts/build-sharp.sh"
fi
diff --git a/scripts/check-env.sh b/scripts/check-env.sh
index b285f43..e94c4ec 100755
--- a/scripts/check-env.sh
+++ b/scripts/check-env.sh
@@ -1,18 +1,14 @@
#!/usr/bin/env bash
-# check-env.sh - Verify Termux environment before installation
set -euo pipefail
-RED='\033[0;31m'
-YELLOW='\033[1;33m'
-GREEN='\033[0;32m'
-NC='\033[0m'
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/lib.sh"
ERRORS=0
echo "=== OpenClaw on Android - Environment Check ==="
echo ""
-# 1. Check if running in Termux
if [ -z "${PREFIX:-}" ]; then
echo -e "${RED}[FAIL]${NC} Not running in Termux (\$PREFIX not set)"
echo " This script is designed for Termux on Android."
@@ -21,7 +17,6 @@ else
echo -e "${GREEN}[OK]${NC} Termux detected (PREFIX=$PREFIX)"
fi
-# 2. Check architecture
ARCH=$(uname -m)
echo -n " Architecture: $ARCH"
if [ "$ARCH" = "aarch64" ]; then
@@ -34,23 +29,14 @@ else
echo -e " ${YELLOW}(unknown, may not work)${NC}"
fi
-# 3. Check disk space (need at least 500MB free)
AVAILABLE_MB=$(df "$PREFIX" 2>/dev/null | awk 'NR==2 {print int($4/1024)}')
-if [ -n "$AVAILABLE_MB" ] && [ "$AVAILABLE_MB" -lt 500 ]; then
- echo -e "${RED}[FAIL]${NC} Insufficient disk space: ${AVAILABLE_MB}MB available (need 500MB+)"
+if [ -n "$AVAILABLE_MB" ] && [ "$AVAILABLE_MB" -lt 1000 ]; then
+ echo -e "${RED}[FAIL]${NC} Insufficient disk space: ${AVAILABLE_MB}MB available (need 1000MB+)"
ERRORS=$((ERRORS + 1))
else
echo -e "${GREEN}[OK]${NC} Disk space: ${AVAILABLE_MB:-unknown}MB available"
fi
-# 4. Check if already installed
-if command -v openclaw &>/dev/null; then
- CURRENT_VER=$(openclaw --version 2>/dev/null || echo "unknown")
- echo -e "${YELLOW}[INFO]${NC} OpenClaw already installed (version: $CURRENT_VER)"
- echo " Re-running install will update/repair the installation."
-fi
-
-# 5. Check if Node.js is already installed and version
if command -v node &>/dev/null; then
NODE_VER=$(node -v 2>/dev/null || echo "unknown")
echo -e "${GREEN}[OK]${NC} Node.js found: $NODE_VER"
@@ -60,7 +46,13 @@ if command -v node &>/dev/null; then
echo -e "${YELLOW}[WARN]${NC} Node.js >= 22 required. Will be upgraded during install."
fi
else
- echo -e "${YELLOW}[INFO]${NC} Node.js not found. Will be installed."
+ echo -e "${YELLOW}[INFO]${NC} Node.js not found. Will be installed via glibc environment."
+fi
+
+SDK_INT=$(getprop ro.build.version.sdk 2>/dev/null || echo "0")
+if [ "$SDK_INT" -ge 31 ] 2>/dev/null; then
+ echo -e "${YELLOW}[INFO]${NC} Android 12+ detected — if background processes get killed (signal 9),"
+ echo " see: https://github.com/AidanPark/openclaw-android/blob/main/docs/disable-phantom-process-killer.md"
fi
echo ""
diff --git a/scripts/install-build-tools.sh b/scripts/install-build-tools.sh
new file mode 100755
index 0000000..772a4e7
--- /dev/null
+++ b/scripts/install-build-tools.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# install-build-tools.sh - Install build tools for native module compilation (L2 conditional)
+# Extracted from install-deps.sh — build tools only.
+# Called by orchestrator when config.env PLATFORM_NEEDS_BUILD_TOOLS=true.
+#
+# Installs: python, make, cmake, clang, binutils
+# These are required for node-gyp (native C/C++ addon compilation).
+set -euo pipefail
+
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+echo "=== Installing Build Tools ==="
+echo ""
+
+PACKAGES=(
+ python
+ make
+ cmake
+ clang
+ binutils
+)
+
+echo "Installing packages: ${PACKAGES[*]}"
+echo " (This may take a few minutes depending on network speed)"
+pkg install -y "${PACKAGES[@]}"
+
+# Create ar symlink if missing (Termux provides llvm-ar but not ar)
+if [ ! -e "$PREFIX/bin/ar" ] && [ -x "$PREFIX/bin/llvm-ar" ]; then
+ ln -s "$PREFIX/bin/llvm-ar" "$PREFIX/bin/ar"
+ echo -e "${GREEN}[OK]${NC} Created ar → llvm-ar symlink"
+fi
+
+echo ""
+echo -e "${GREEN}Build tools installed.${NC}"
diff --git a/scripts/install-chromium.sh b/scripts/install-chromium.sh
new file mode 100644
index 0000000..45c0fb7
--- /dev/null
+++ b/scripts/install-chromium.sh
@@ -0,0 +1,150 @@
+#!/usr/bin/env bash
+# install-chromium.sh - Install Chromium for OpenClaw browser automation
+# Usage: bash install-chromium.sh [install|update]
+#
+# What it does:
+# 1. Install x11-repo (Termux X11 packages repository)
+# 2. Install chromium package
+# 3. Configure OpenClaw browser settings in openclaw.json
+# 4. Verify installation
+#
+# Browser automation allows OpenClaw to control a headless Chromium browser
+# for web scraping, screenshots, and automated browsing tasks.
+#
+# This script is WARN-level: failure does not abort the parent installer.
+set -euo pipefail
+
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+MODE="${1:-install}"
+
+# ── Helper ────────────────────────────────────
+
+fail_warn() {
+ echo -e "${YELLOW}[WARN]${NC} $1"
+ exit 0
+}
+
+# ── Detect Chromium binary path ───────────────
+
+detect_chromium_bin() {
+ for bin in "$PREFIX/bin/chromium-browser" "$PREFIX/bin/chromium"; do
+ if [ -x "$bin" ]; then
+ echo "$bin"
+ return 0
+ fi
+ done
+ return 1
+}
+
+# ── Pre-checks ────────────────────────────────
+
+if [ -z "${PREFIX:-}" ]; then
+ fail_warn "Not running in Termux (\$PREFIX not set)"
+fi
+
+# ── Check current installation ────────────────
+
+SKIP_PKG_INSTALL=false
+if CHROMIUM_BIN=$(detect_chromium_bin); then
+ if [ "$MODE" = "install" ]; then
+ echo -e "${GREEN}[SKIP]${NC} Chromium already installed ($CHROMIUM_BIN)"
+ SKIP_PKG_INSTALL=true
+ fi
+fi
+
+# ── Step 1: Install x11-repo + Chromium ───────
+
+if [ "$SKIP_PKG_INSTALL" = false ]; then
+ echo "Installing x11-repo (Termux X11 packages)..."
+ if ! pkg install -y x11-repo; then
+ fail_warn "Failed to install x11-repo"
+ fi
+ echo -e "${GREEN}[OK]${NC} x11-repo installed"
+
+ echo "Installing Chromium..."
+ echo " (This is a large package (~400MB) — may take several minutes)"
+ if ! pkg install -y chromium; then
+ fail_warn "Failed to install Chromium"
+ fi
+ echo -e "${GREEN}[OK]${NC} Chromium installed"
+fi
+
+# ── Step 2: Detect binary path ────────────────
+
+if ! CHROMIUM_BIN=$(detect_chromium_bin); then
+ fail_warn "Chromium binary not found after installation"
+fi
+
+# ── Step 3: Configure OpenClaw browser settings
+
+echo "Configuring OpenClaw browser settings..."
+
+if command -v node &>/dev/null; then
+ export CHROMIUM_BIN
+ if node << 'NODESCRIPT'
+const fs = require('fs');
+const path = require('path');
+
+const configDir = path.join(process.env.HOME, '.openclaw');
+const configPath = path.join(configDir, 'openclaw.json');
+
+let config = {};
+try {
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+} catch {
+ // File doesn't exist or invalid — start fresh
+}
+
+if (!config.browser) config.browser = {};
+config.browser.executablePath = process.env.CHROMIUM_BIN;
+if (config.browser.headless === undefined) config.browser.headless = true;
+if (config.browser.noSandbox === undefined) config.browser.noSandbox = true;
+
+fs.mkdirSync(configDir, { recursive: true });
+fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
+console.log(' Written to ' + configPath);
+NODESCRIPT
+ then
+ echo -e "${GREEN}[OK]${NC} openclaw.json browser settings configured"
+ else
+ echo -e "${YELLOW}[WARN]${NC} Could not update openclaw.json automatically"
+ echo " Add this to ~/.openclaw/openclaw.json manually:"
+ echo " \"browser\": {\"executablePath\": \"$CHROMIUM_BIN\", \"headless\": true, \"noSandbox\": true}"
+ fi
+else
+ echo -e "${YELLOW}[INFO]${NC} Node.js not available — manual browser configuration needed"
+ echo " After running 'openclaw onboard', add to ~/.openclaw/openclaw.json:"
+ echo " \"browser\": {\"executablePath\": \"$CHROMIUM_BIN\", \"headless\": true, \"noSandbox\": true}"
+fi
+
+# ── Step 4: Verify ────────────────────────────
+
+echo ""
+if [ -x "$CHROMIUM_BIN" ]; then
+ CHROMIUM_VER=$("$CHROMIUM_BIN" --version 2>/dev/null || echo "unknown version")
+ echo -e "${GREEN}[OK]${NC} $CHROMIUM_VER"
+ echo " Binary: $CHROMIUM_BIN"
+ echo ""
+ echo -e "${YELLOW}[NOTE]${NC} Chromium uses ~300-500MB RAM at runtime."
+ echo " Devices with less than 4GB RAM may experience slowdowns."
+else
+ fail_warn "Chromium verification failed — binary not executable"
+fi
+
+# ── Step 5: Ensure image processing works ────
+#
+# Browser screenshots require sharp for image optimization before sending
+# to Discord/Slack. Run build-sharp.sh to enable it (idempotent — skips
+# if sharp is already working).
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+if [ -f "$SCRIPT_DIR/build-sharp.sh" ]; then
+ echo ""
+ bash "$SCRIPT_DIR/build-sharp.sh" || true
+elif [ -f "$HOME/.openclaw-android/scripts/build-sharp.sh" ]; then
+ echo ""
+ bash "$HOME/.openclaw-android/scripts/build-sharp.sh" || true
+fi
diff --git a/scripts/install-code-server.sh b/scripts/install-code-server.sh
new file mode 100644
index 0000000..895119e
--- /dev/null
+++ b/scripts/install-code-server.sh
@@ -0,0 +1,193 @@
+#!/usr/bin/env bash
+# install-code-server.sh - Install or update code-server (browser IDE) on Termux
+# Usage: bash install-code-server.sh [install|update]
+#
+# Workarounds applied:
+# 1. Replace bundled glibc node with Termux node
+# 2. Patch argon2 native module with JS stub (--auth none makes it unused)
+# 3. Ignore tar hard link errors (Android restriction) and recover .node files
+#
+# This script is WARN-level: failure does not abort the parent installer.
+set -euo pipefail
+
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+MODE="${1:-install}"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+INSTALL_DIR="$HOME/.local/lib"
+BIN_DIR="$HOME/.local/bin"
+
+# ── Helper ────────────────────────────────────
+
+fail_warn() {
+ echo -e "${YELLOW}[WARN]${NC} $1"
+ exit 0
+}
+
+# ── Pre-checks ────────────────────────────────
+
+if [ -z "${PREFIX:-}" ]; then
+ fail_warn "Not running in Termux (\$PREFIX not set)"
+fi
+
+if ! command -v node &>/dev/null; then
+ fail_warn "node not found — code-server requires Node.js"
+fi
+
+if ! command -v curl &>/dev/null; then
+ fail_warn "curl not found — cannot download code-server"
+fi
+
+# ── Check current installation ────────────────
+
+CURRENT_VERSION=""
+if [ -x "$BIN_DIR/code-server" ]; then
+ # code-server --version outputs: "4.109.2 9184b645cc... with Code 1.109.2"
+ # Extract just the version number (first field)
+ CURRENT_VERSION=$("$BIN_DIR/code-server" --version 2>/dev/null | head -1 | awk '{print $1}' || true)
+fi
+
+# ── Determine target version ──────────────────
+
+if [ "$MODE" = "install" ] && [ -n "$CURRENT_VERSION" ]; then
+ echo -e "${GREEN}[SKIP]${NC} code-server already installed ($CURRENT_VERSION)"
+ exit 0
+fi
+
+# Fetch latest version from GitHub API
+echo "Checking latest code-server version..."
+LATEST_VERSION=$(curl -sfL --max-time 10 \
+ "https://api.github.com/repos/coder/code-server/releases/latest" \
+ | grep '"tag_name"' | head -1 | sed 's/.*"v\([^"]*\)".*/\1/') || true
+
+if [ -z "$LATEST_VERSION" ]; then
+ fail_warn "Failed to fetch latest code-server version from GitHub"
+fi
+
+echo " Latest: v$LATEST_VERSION"
+
+if [ "$MODE" = "update" ] && [ -n "$CURRENT_VERSION" ]; then
+ if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
+ echo -e "${GREEN}[SKIP]${NC} code-server $CURRENT_VERSION is already the latest"
+ exit 0
+ fi
+ echo " Current: v$CURRENT_VERSION → updating to v$LATEST_VERSION"
+fi
+
+VERSION="$LATEST_VERSION"
+
+# ── Download ──────────────────────────────────
+
+TARBALL="code-server-${VERSION}-linux-arm64.tar.gz"
+DOWNLOAD_URL="https://github.com/coder/code-server/releases/download/v${VERSION}/${TARBALL}"
+TMP_DIR=$(mktemp -d "$PREFIX/tmp/code-server-install.XXXXXX") || fail_warn "Failed to create temp directory"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+echo "Downloading code-server v${VERSION}..."
+echo " (File size ~121MB — this may take several minutes depending on network speed)"
+if ! curl -fL --max-time 300 "$DOWNLOAD_URL" -o "$TMP_DIR/$TARBALL"; then
+ fail_warn "Failed to download code-server v${VERSION}"
+fi
+echo -e "${GREEN}[OK]${NC} Downloaded $TARBALL"
+
+# ── Extract (ignore hard link errors) ─────────
+
+echo "Extracting code-server... (this may take a moment)"
+# Android's filesystem does not support hard links, so tar will report errors
+# for hardlinked .node files. We extract what we can and recover them below.
+tar -xzf "$TMP_DIR/$TARBALL" -C "$TMP_DIR" 2>/dev/null || true
+
+EXTRACTED_DIR="$TMP_DIR/code-server-${VERSION}-linux-arm64"
+if [ ! -d "$EXTRACTED_DIR" ]; then
+ fail_warn "Extraction failed — directory not found"
+fi
+
+# ── Recover hard-linked .node files ───────────
+# The obj.target/ directories contain the original .node files that tar
+# couldn't hard-link into Release/. Copy them manually.
+
+find "$EXTRACTED_DIR" -path "*/obj.target/*.node" -type f 2>/dev/null | while read -r OBJ_FILE; do
+ # obj.target/foo.node → Release/foo.node
+ RELEASE_DIR="$(dirname "$(dirname "$OBJ_FILE")")/Release"
+ BASENAME="$(basename "$OBJ_FILE")"
+ if [ -d "$RELEASE_DIR" ] && [ ! -f "$RELEASE_DIR/$BASENAME" ]; then
+ cp "$OBJ_FILE" "$RELEASE_DIR/$BASENAME"
+ fi
+done
+echo -e "${GREEN}[OK]${NC} Extracted and recovered .node files"
+
+# ── Install to ~/.local/lib ───────────────────
+
+mkdir -p "$INSTALL_DIR" "$BIN_DIR"
+
+# Remove previous code-server versions
+rm -rf "$INSTALL_DIR"/code-server-*
+
+# Move extracted directory to install location
+mv "$EXTRACTED_DIR" "$INSTALL_DIR/code-server-${VERSION}"
+echo -e "${GREEN}[OK]${NC} Installed to $INSTALL_DIR/code-server-${VERSION}"
+
+CS_DIR="$INSTALL_DIR/code-server-${VERSION}"
+
+# ── Replace bundled node with Termux node ─────
+# The standalone release bundles a glibc-linked node binary that cannot
+# run on Termux (Bionic libc). Swap it with the system node.
+
+if [ -f "$CS_DIR/lib/node" ] || [ -L "$CS_DIR/lib/node" ]; then
+ rm -f "$CS_DIR/lib/node"
+fi
+ln -s "$PREFIX/bin/node" "$CS_DIR/lib/node"
+echo -e "${GREEN}[OK]${NC} Replaced bundled node → Termux node"
+
+# ── Patch argon2 native module ────────────────
+# argon2 ships a .node binary compiled against glibc. Since we run
+# code-server with --auth none, argon2 is never called. Replace the
+# module entry point with a JS stub.
+
+ARGON2_STUB=""
+# Check multiple possible locations for the stub
+if [ -f "$SCRIPT_DIR/../patches/argon2-stub.js" ]; then
+ ARGON2_STUB="$SCRIPT_DIR/../patches/argon2-stub.js"
+elif [ -f "$HOME/.openclaw-android/patches/argon2-stub.js" ]; then
+ ARGON2_STUB="$HOME/.openclaw-android/patches/argon2-stub.js"
+fi
+
+if [ -n "$ARGON2_STUB" ]; then
+ # Find argon2 module entry point in code-server
+ # Entry point varies by version: argon2.cjs (v4.109+), argon2.js, or index.js
+ ARGON2_INDEX=""
+ for PATTERN in "*/argon2/argon2.cjs" "*/argon2/argon2.js" "*/node_modules/argon2/index.js"; do
+ ARGON2_INDEX=$(find "$CS_DIR" -path "$PATTERN" -type f 2>/dev/null | head -1 || true)
+ [ -n "$ARGON2_INDEX" ] && break
+ done
+ if [ -n "$ARGON2_INDEX" ]; then
+ cp "$ARGON2_STUB" "$ARGON2_INDEX"
+ echo -e "${GREEN}[OK]${NC} Patched argon2 module with JS stub ($(basename "$ARGON2_INDEX"))"
+ else
+ echo -e "${YELLOW}[WARN]${NC} argon2 module not found in code-server (may not be needed)"
+ fi
+else
+ echo -e "${YELLOW}[WARN]${NC} argon2-stub.js not found — skipping argon2 patch"
+fi
+
+# ── Create symlink ────────────────────────────
+
+rm -f "$BIN_DIR/code-server"
+ln -s "$CS_DIR/bin/code-server" "$BIN_DIR/code-server"
+echo -e "${GREEN}[OK]${NC} Symlinked $BIN_DIR/code-server"
+
+# ── Verify ────────────────────────────────────
+
+# Add ~/.local/bin to PATH for this session so we can verify with just "code-server"
+export PATH="$BIN_DIR:$PATH"
+
+echo ""
+if code-server --version &>/dev/null; then
+ INSTALLED_VER=$(code-server --version 2>/dev/null | head -1 || true)
+ echo -e "${GREEN}[OK]${NC} code-server ${INSTALLED_VER:-unknown} installed successfully"
+else
+ echo -e "${YELLOW}[WARN]${NC} code-server installed but --version check failed"
+ echo " This may work once ~/.local/bin is on PATH (restart shell or: source ~/.bashrc)"
+fi
diff --git a/scripts/install-deps.sh b/scripts/install-deps.sh
deleted file mode 100755
index 149c99f..0000000
--- a/scripts/install-deps.sh
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/usr/bin/env bash
-# install-deps.sh - Install required Termux packages
-set -euo pipefail
-
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-NC='\033[0m'
-
-echo "=== Installing Dependencies ==="
-echo ""
-
-# Update package repos
-echo "Updating package repositories..."
-pkg update -y
-
-# Install required packages
-PACKAGES=(
- nodejs-lts
- git
- python
- make
- cmake
- clang
- binutils
- tmux
- ttyd
-)
-
-echo "Installing packages: ${PACKAGES[*]}"
-pkg install -y "${PACKAGES[@]}"
-
-echo ""
-
-# Verify Node.js version
-if ! command -v node &>/dev/null; then
- echo -e "${RED}[FAIL]${NC} Node.js installation failed"
- exit 1
-fi
-
-NODE_VER=$(node -v)
-NODE_MAJOR="${NODE_VER%%.*}"
-NODE_MAJOR="${NODE_MAJOR#v}"
-
-echo -e "${GREEN}[OK]${NC} Node.js $NODE_VER installed"
-
-if [ "$NODE_MAJOR" -lt 22 ]; then
- echo -e "${RED}[FAIL]${NC} Node.js >= 22 required, got $NODE_VER"
- echo " Try: pkg install nodejs-lts"
- exit 1
-fi
-
-# Verify npm
-if ! command -v npm &>/dev/null; then
- echo -e "${RED}[FAIL]${NC} npm not found"
- exit 1
-fi
-
-NPM_VER=$(npm -v)
-echo -e "${GREEN}[OK]${NC} npm $NPM_VER installed"
-
-# Install PyYAML (required for .skill packaging)
-echo "Installing PyYAML..."
-if pip install pyyaml -q; then
- echo -e "${GREEN}[OK]${NC} PyYAML installed"
-else
- echo -e "${RED}[FAIL]${NC} PyYAML installation failed"
- exit 1
-fi
-
-echo ""
-echo -e "${GREEN}All dependencies installed.${NC}"
diff --git a/scripts/install-glibc.sh b/scripts/install-glibc.sh
new file mode 100755
index 0000000..14d0d69
--- /dev/null
+++ b/scripts/install-glibc.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+# install-glibc.sh - Install glibc-runner (L2 conditional)
+# Extracted from install-glibc-env.sh — glibc runtime only, no Node.js.
+# Called by orchestrator when config.env PLATFORM_NEEDS_GLIBC=true.
+#
+# What it does:
+# 1. Install pacman package
+# 2. Initialize pacman and install glibc-runner
+# 3. Verify glibc dynamic linker
+# 4. Create marker file
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+OPENCLAW_DIR="$HOME/.openclaw-android"
+GLIBC_LDSO="$PREFIX/glibc/lib/ld-linux-aarch64.so.1"
+PACMAN_CONF="$PREFIX/etc/pacman.conf"
+
+echo "=== Installing glibc Runtime ==="
+echo ""
+
+# ── Pre-checks ───────────────────────────────
+
+if [ -z "${PREFIX:-}" ]; then
+ echo -e "${RED}[FAIL]${NC} Not running in Termux (\$PREFIX not set)"
+ exit 1
+fi
+
+ARCH=$(uname -m)
+if [ "$ARCH" != "aarch64" ]; then
+ echo -e "${RED}[FAIL]${NC} glibc environment requires aarch64 (got: $ARCH)"
+ exit 1
+fi
+
+# Check if already installed
+if [ -f "$OPENCLAW_DIR/.glibc-arch" ] && [ -x "$GLIBC_LDSO" ]; then
+ echo -e "${GREEN}[SKIP]${NC} glibc-runner already installed"
+ exit 0
+fi
+
+# ── Step 1: Install pacman ────────────────────
+
+echo "Installing pacman..."
+if ! pkg install -y pacman; then
+ echo -e "${RED}[FAIL]${NC} Failed to install pacman"
+ exit 1
+fi
+echo -e "${GREEN}[OK]${NC} pacman installed"
+
+# ── Step 2: Initialize pacman ─────────────────
+
+echo ""
+echo "Initializing pacman..."
+echo " (This may take a few minutes for GPG key generation)"
+
+# SigLevel workaround: Some devices have a GPGME crypto engine bug
+# that prevents signature verification. Temporarily set SigLevel = Never.
+SIGLEVEL_PATCHED=false
+if [ -f "$PACMAN_CONF" ]; then
+ if ! grep -q "^SigLevel = Never" "$PACMAN_CONF"; then
+ cp "$PACMAN_CONF" "${PACMAN_CONF}.bak"
+ sed -i 's/^SigLevel\s*=.*/SigLevel = Never/' "$PACMAN_CONF"
+ SIGLEVEL_PATCHED=true
+ echo -e "${YELLOW}[INFO]${NC} Applied SigLevel = Never workaround (GPGME bug)"
+ fi
+fi
+
+# Initialize pacman keyring (may hang on low-entropy devices)
+pacman-key --init 2>/dev/null || true
+pacman-key --populate 2>/dev/null || true
+
+# ── Step 3: Install glibc-runner ──────────────
+
+echo ""
+echo "Installing glibc-runner..."
+
+# --assume-installed: these packages are provided by Termux's apt but pacman
+# doesn't know about them, causing dependency resolution failures
+if pacman -Sy glibc-runner --noconfirm --assume-installed bash,patchelf,resolv-conf 2>&1; then
+ echo -e "${GREEN}[OK]${NC} glibc-runner installed"
+else
+ echo -e "${RED}[FAIL]${NC} Failed to install glibc-runner"
+ if [ "$SIGLEVEL_PATCHED" = true ] && [ -f "${PACMAN_CONF}.bak" ]; then
+ mv "${PACMAN_CONF}.bak" "$PACMAN_CONF"
+ fi
+ exit 1
+fi
+
+# Restore SigLevel after successful install
+if [ "$SIGLEVEL_PATCHED" = true ] && [ -f "${PACMAN_CONF}.bak" ]; then
+ mv "${PACMAN_CONF}.bak" "$PACMAN_CONF"
+ echo -e "${GREEN}[OK]${NC} Restored pacman SigLevel"
+fi
+
+# ── Verify ────────────────────────────────────
+
+if [ ! -x "$GLIBC_LDSO" ]; then
+ echo -e "${RED}[FAIL]${NC} glibc dynamic linker not found at $GLIBC_LDSO"
+ exit 1
+fi
+echo -e "${GREEN}[OK]${NC} glibc dynamic linker available"
+
+if command -v grun &>/dev/null; then
+ echo -e "${GREEN}[OK]${NC} grun command available"
+else
+ echo -e "${YELLOW}[WARN]${NC} grun command not found (will use ld.so directly)"
+fi
+
+# ── Create marker file ────────────────────────
+
+mkdir -p "$OPENCLAW_DIR"
+touch "$OPENCLAW_DIR/.glibc-arch"
+echo -e "${GREEN}[OK]${NC} glibc architecture marker created"
+
+echo ""
+echo -e "${GREEN}glibc runtime installed successfully.${NC}"
+echo " ld.so: $GLIBC_LDSO"
diff --git a/scripts/install-infra-deps.sh b/scripts/install-infra-deps.sh
new file mode 100755
index 0000000..88e6ba5
--- /dev/null
+++ b/scripts/install-infra-deps.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# install-infra-deps.sh - Install core infrastructure packages (L1)
+# Extracted from install-deps.sh — infrastructure only.
+# Always runs regardless of platform selection.
+#
+# Installs: git (+ pkg update/upgrade)
+set -euo pipefail
+
+GREEN='\033[0;32m'
+NC='\033[0m'
+
+echo "=== Installing Infrastructure Dependencies ==="
+echo ""
+
+# Update and upgrade package repos
+echo "Updating package repositories..."
+echo " (This may take a minute depending on mirror speed)"
+pkg update -y
+pkg upgrade -y
+
+# Install core infrastructure packages
+echo "Installing git..."
+pkg install -y git
+
+echo ""
+echo -e "${GREEN}Infrastructure dependencies installed.${NC}"
diff --git a/scripts/install-nodejs.sh b/scripts/install-nodejs.sh
new file mode 100755
index 0000000..8cfbd9e
--- /dev/null
+++ b/scripts/install-nodejs.sh
@@ -0,0 +1,183 @@
+#!/usr/bin/env bash
+# install-nodejs.sh - Install Node.js linux-arm64 with grun wrapper (L2 conditional)
+# Extracted from install-glibc-env.sh — Node.js only, assumes glibc already installed.
+# Called by orchestrator when config.env PLATFORM_NEEDS_NODEJS=true.
+#
+# What it does:
+# 1. Download Node.js linux-arm64 LTS
+# 2. Create grun-style wrapper scripts (ld.so direct execution)
+# 3. Configure npm
+# 4. Verify everything works
+#
+# patchelf is NOT used — Android seccomp causes SIGSEGV on patchelf'd binaries.
+# All glibc binaries are executed via: exec ld.so binary "$@"
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+OPENCLAW_DIR="$HOME/.openclaw-android"
+NODE_DIR="$OPENCLAW_DIR/node"
+GLIBC_LDSO="$PREFIX/glibc/lib/ld-linux-aarch64.so.1"
+
+# Node.js LTS version to install
+NODE_VERSION="22.22.0"
+NODE_TARBALL="node-v${NODE_VERSION}-linux-arm64.tar.xz"
+NODE_URL="https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TARBALL}"
+
+echo "=== Installing Node.js (glibc) ==="
+echo ""
+
+# ── Pre-checks ───────────────────────────────
+
+if [ -z "${PREFIX:-}" ]; then
+ echo -e "${RED}[FAIL]${NC} Not running in Termux (\$PREFIX not set)"
+ exit 1
+fi
+
+if [ ! -x "$GLIBC_LDSO" ]; then
+ echo -e "${RED}[FAIL]${NC} glibc dynamic linker not found — run install-glibc.sh first"
+ exit 1
+fi
+
+# Check if already installed
+if [ -x "$NODE_DIR/bin/node" ]; then
+ if "$NODE_DIR/bin/node" --version &>/dev/null; then
+ INSTALLED_VER=$("$NODE_DIR/bin/node" --version 2>/dev/null | sed 's/^v//')
+ if [ "$INSTALLED_VER" = "$NODE_VERSION" ]; then
+ echo -e "${GREEN}[SKIP]${NC} Node.js already installed (v${INSTALLED_VER})"
+ exit 0
+ fi
+ LOWEST=$(printf '%s\n%s\n' "$INSTALLED_VER" "$NODE_VERSION" | sort -V | head -1)
+ if [ "$LOWEST" = "$INSTALLED_VER" ] && [ "$INSTALLED_VER" != "$NODE_VERSION" ]; then
+ echo -e "${YELLOW}[INFO]${NC} Node.js v${INSTALLED_VER} -> v${NODE_VERSION} (upgrading)"
+ else
+ echo -e "${GREEN}[SKIP]${NC} Node.js v${INSTALLED_VER} is newer than target v${NODE_VERSION}"
+ exit 0
+ fi
+ else
+ echo -e "${YELLOW}[INFO]${NC} Node.js exists but broken — reinstalling"
+ fi
+fi
+
+# ── Step 1: Download Node.js linux-arm64 ──────
+
+echo "Downloading Node.js v${NODE_VERSION} (linux-arm64)..."
+echo " (File size ~25MB — may take a few minutes depending on network speed)"
+mkdir -p "$NODE_DIR"
+
+TMP_DIR=$(mktemp -d "$PREFIX/tmp/node-install.XXXXXX") || {
+ echo -e "${RED}[FAIL]${NC} Failed to create temp directory"
+ exit 1
+}
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+if ! curl -fL --max-time 300 "$NODE_URL" -o "$TMP_DIR/$NODE_TARBALL"; then
+ echo -e "${RED}[FAIL]${NC} Failed to download Node.js v${NODE_VERSION}"
+ exit 1
+fi
+echo -e "${GREEN}[OK]${NC} Downloaded $NODE_TARBALL"
+
+# Extract
+echo "Extracting Node.js... (this may take a moment)"
+if ! tar -xJf "$TMP_DIR/$NODE_TARBALL" -C "$NODE_DIR" --strip-components=1; then
+ echo -e "${RED}[FAIL]${NC} Failed to extract Node.js"
+ exit 1
+fi
+echo -e "${GREEN}[OK]${NC} Extracted to $NODE_DIR"
+
+# ── Step 2: Create wrapper scripts ────────────
+
+echo ""
+echo "Creating wrapper scripts (grun-style, no patchelf)..."
+
+# Move original node binary to node.real
+if [ -f "$NODE_DIR/bin/node" ] && [ ! -L "$NODE_DIR/bin/node" ]; then
+ mv "$NODE_DIR/bin/node" "$NODE_DIR/bin/node.real"
+fi
+
+# Create node wrapper script
+# This uses grun-style execution: ld.so directly loads the binary
+# LD_PRELOAD must be unset to prevent Bionic libtermux-exec.so from
+# being loaded into the glibc process (causes version mismatch crash)
+# glibc-compat.js is auto-loaded to fix Android kernel quirks (os.cpus() returns 0,
+# os.networkInterfaces() throws EACCES) that affect native module builds and runtime.
+cat > "$NODE_DIR/bin/node" << 'WRAPPER'
+#!/data/data/com.termux/files/usr/bin/bash
+unset LD_PRELOAD
+_OA_COMPAT="$HOME/.openclaw-android/patches/glibc-compat.js"
+if [ -f "$_OA_COMPAT" ]; then
+ case "${NODE_OPTIONS:-}" in
+ *"$_OA_COMPAT"*) ;;
+ *) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }-r $_OA_COMPAT" ;;
+ esac
+fi
+# glibc ld.so misparses leading --options as its own flags.
+# Move them to NODE_OPTIONS ONLY when a script path follows
+# (preserves direct invocations like 'node --version').
+_LEADING_OPTS=""
+_COUNT=0
+for _arg in "$@"; do
+ case "$_arg" in --*) _COUNT=$((_COUNT + 1)) ;; *) break ;; esac
+done
+if [ $_COUNT -gt 0 ] && [ $_COUNT -lt $# ]; then
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --*) _LEADING_OPTS="${_LEADING_OPTS:+$_LEADING_OPTS }$1"; shift ;;
+ *) break ;;
+ esac
+ done
+ export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_LEADING_OPTS"
+fi
+exec "$PREFIX/glibc/lib/ld-linux-aarch64.so.1" "$(dirname "$0")/node.real" "$@"
+WRAPPER
+chmod +x "$NODE_DIR/bin/node"
+echo -e "${GREEN}[OK]${NC} node wrapper created"
+
+# npm is a JS script that uses the node from its own directory,
+# so it automatically inherits the wrapper. No additional wrapping needed.
+# Same for npx.
+
+# ── Step 3: Configure npm ─────────────────────
+
+echo ""
+echo "Configuring npm..."
+
+# Set script-shell to ensure npm lifecycle scripts use the correct shell
+# On Android 9+, /bin/sh exists. On 7-8 it doesn't.
+# Using $PREFIX/bin/sh is always safe.
+export PATH="$NODE_DIR/bin:$PATH"
+"$NODE_DIR/bin/npm" config set script-shell "$PREFIX/bin/sh" 2>/dev/null || true
+echo -e "${GREEN}[OK]${NC} npm script-shell set to $PREFIX/bin/sh"
+
+# ── Step 4: Verify ────────────────────────────
+
+echo ""
+echo "Verifying glibc Node.js..."
+
+NODE_VER=$("$NODE_DIR/bin/node" --version 2>/dev/null) || {
+ echo -e "${RED}[FAIL]${NC} Node.js verification failed — wrapper script may be broken"
+ exit 1
+}
+echo -e "${GREEN}[OK]${NC} Node.js $NODE_VER (glibc, grun wrapper)"
+
+NPM_VER=$("$NODE_DIR/bin/npm" --version 2>/dev/null) || {
+ echo -e "${YELLOW}[WARN]${NC} npm verification failed"
+}
+if [ -n "${NPM_VER:-}" ]; then
+ echo -e "${GREEN}[OK]${NC} npm $NPM_VER"
+fi
+
+# Quick platform check
+PLATFORM=$("$NODE_DIR/bin/node" -e "console.log(process.platform)" 2>/dev/null) || true
+if [ "$PLATFORM" = "linux" ]; then
+ echo -e "${GREEN}[OK]${NC} platform: linux (correct)"
+else
+ echo -e "${YELLOW}[WARN]${NC} platform: ${PLATFORM:-unknown} (expected: linux)"
+fi
+
+echo ""
+echo -e "${GREEN}Node.js installed successfully.${NC}"
+echo " Node.js: $NODE_VER ($NODE_DIR/bin/node)"
diff --git a/scripts/install-opencode.sh b/scripts/install-opencode.sh
new file mode 100644
index 0000000..0bdf742
--- /dev/null
+++ b/scripts/install-opencode.sh
@@ -0,0 +1,231 @@
+#!/usr/bin/env bash
+# install-opencode.sh - Install OpenCode on Termux
+# Uses proot + ld.so concatenation for Bun standalone binaries.
+#
+# This script is NON-CRITICAL: failure does not affect OpenClaw.
+#
+# Why proot + ld.so concatenation?
+# 1. Bun uses raw syscalls (LD_PRELOAD shims don't work)
+# 2. patchelf causes SIGSEGV on Android (seccomp)
+# 3. Bun standalone reads embedded JS via /proc/self/exe offset
+# → grun makes /proc/self/exe point to ld.so, breaking this
+# → concatenating ld.so + binary data fixes the offset math
+set -euo pipefail
+
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m'
+
+OPENCLAW_DIR="$HOME/.openclaw-android"
+GLIBC_LDSO="$PREFIX/glibc/lib/ld-linux-aarch64.so.1"
+PROOT_ROOT="$OPENCLAW_DIR/proot-root"
+
+fail_warn() {
+ echo -e "${YELLOW}[WARN]${NC} $1"
+ exit 0
+}
+
+echo "=== Installing OpenCode ==="
+echo ""
+
+# ── Pre-checks ───────────────────────────────
+
+if [ ! -f "$OPENCLAW_DIR/.glibc-arch" ]; then
+ fail_warn "glibc environment not installed — skipping OpenCode install"
+fi
+
+if [ ! -x "$GLIBC_LDSO" ]; then
+ fail_warn "glibc dynamic linker not found — skipping OpenCode install"
+fi
+
+if ! command -v proot &>/dev/null; then
+ echo "Installing proot..."
+ if ! pkg install -y proot; then
+ fail_warn "Failed to install proot — skipping OpenCode install"
+ fi
+fi
+
+# ── Helper: Create ld.so concatenation ───────
+
+# Bun standalone binaries store embedded JS at the end of the file.
+# The last 8 bytes contain the original file size as a LE u64.
+# Bun calculates: embedded_offset = current_file_size - stored_size
+# By prepending ld.so, current_file_size increases, and the offset
+# shifts correctly to find the embedded data after ld.so.
+create_ldso_concat() {
+ local bin_path="$1"
+ local output_path="$2"
+ local name="$3"
+
+ if [ ! -f "$bin_path" ]; then
+ echo -e "${RED}[FAIL]${NC} $name binary not found at $bin_path"
+ return 1
+ fi
+
+echo " Creating ld.so concatenation for $name..."
+echo " (Copying large binary files — this may take a minute)"
+cp "$GLIBC_LDSO" "$output_path"
+cat "$bin_path" >> "$output_path"
+chmod +x "$output_path"
+
+ # Verify the Bun magic marker exists at the end
+ local marker
+ marker=$(tail -c 32 "$output_path" | strings 2>/dev/null | grep -o "Bun" || true)
+ if [ -n "$marker" ]; then
+ echo -e "${GREEN}[OK]${NC} $name ld.so concatenation created ($(du -h "$output_path" | cut -f1))"
+ else
+ echo -e "${YELLOW}[WARN]${NC} $name ld.so concatenation created but Bun marker not found"
+ fi
+}
+
+# ── Helper: Create proot wrapper script ──────
+
+create_proot_wrapper() {
+ local wrapper_path="$1"
+ local ldso_path="$2"
+ local bin_path="$3"
+ local name="$4"
+
+ cat > "$wrapper_path" << WRAPPER
+#!/data/data/com.termux/files/usr/bin/bash
+# $name wrapper — proot + ld.so concatenation
+# proot: intercepts raw syscalls (Bun uses inline asm, not glibc calls)
+# ld.so concat: fixes /proc/self/exe offset for embedded JS
+# unset LD_PRELOAD: prevents Bionic libtermux-exec.so version mismatch
+unset LD_PRELOAD
+exec proot \\
+ -R "$PROOT_ROOT" \\
+ -b "\$PREFIX:\$PREFIX" \\
+ -b /system:/system \\
+ -b /apex:/apex \\
+ -w "\$(pwd)" \\
+ "$ldso_path" "$bin_path" "\$@"
+WRAPPER
+ chmod +x "$wrapper_path"
+ echo -e "${GREEN}[OK]${NC} $name wrapper script created"
+}
+
+# ── Step 1: Create minimal proot rootfs ──────
+
+echo "Setting up proot minimal rootfs..."
+mkdir -p "$PROOT_ROOT/data/data/com.termux/files"
+echo -e "${GREEN}[OK]${NC} proot rootfs created at $PROOT_ROOT"
+
+# ── Step 2: Install Bun (package manager) ────
+
+echo ""
+echo "Installing Bun..."
+echo " (Downloading and installing Bun runtime — this may take a few minutes)"
+BUN_BIN="$HOME/.bun/bin/bun"
+if [ -x "$BUN_BIN" ]; then
+ echo -e "${GREEN}[OK]${NC} Bun already installed"
+else
+ # Install bun via the official installer
+ # Bun is needed to download the opencode package
+ if curl -fsSL https://bun.sh/install | bash 2>/dev/null; then
+ echo -e "${GREEN}[OK]${NC} Bun installed"
+ else
+ fail_warn "Failed to install Bun — cannot install OpenCode"
+ fi
+ BUN_BIN="$HOME/.bun/bin/bun"
+fi
+
+# Bun itself needs grun to run (it's a glibc binary)
+# Create a temporary wrapper for bun
+BUN_WRAPPER=$(mktemp "$PREFIX/tmp/bun-wrapper.XXXXXX")
+cat > "$BUN_WRAPPER" << WRAPPER
+#!/data/data/com.termux/files/usr/bin/bash
+unset LD_PRELOAD
+exec "$GLIBC_LDSO" "$BUN_BIN" "\$@"
+WRAPPER
+chmod +x "$BUN_WRAPPER"
+
+# Verify bun works
+BUN_VER=$("$BUN_WRAPPER" --version 2>/dev/null) || {
+ rm -f "$BUN_WRAPPER"
+ fail_warn "Bun verification failed"
+}
+echo -e "${GREEN}[OK]${NC} Bun $BUN_VER verified"
+
+# ── Step 3: Install OpenCode ────────────────
+
+echo ""
+echo "Installing OpenCode..."
+echo " (Downloading package — this may take a few minutes)"
+# Use bun to install opencode-ai package
+# Note: bun may exit non-zero due to optional platform packages (windows, darwin)
+# failing to install, but the linux-arm64 binary is still installed successfully.
+"$BUN_WRAPPER" install -g opencode-ai 2>&1 || true
+echo -e "${GREEN}[OK]${NC} opencode-ai package install attempted"
+
+# Find the OpenCode binary
+OPENCODE_BIN=""
+for pattern in \
+ "$HOME/.bun/install/cache/opencode-linux-arm64@*/bin/opencode" \
+ "$HOME/.bun/install/global/node_modules/opencode-linux-arm64/bin/opencode"; do
+ # Use ls to expand glob safely
+ FOUND=$(ls $pattern 2>/dev/null | sort -V | tail -1 || true)
+ if [ -n "$FOUND" ] && [ -f "$FOUND" ]; then
+ OPENCODE_BIN="$FOUND"
+ break
+ fi
+done
+
+if [ -z "$OPENCODE_BIN" ]; then
+ rm -f "$BUN_WRAPPER"
+ fail_warn "OpenCode binary not found after installation"
+fi
+echo -e "${GREEN}[OK]${NC} OpenCode binary found: $OPENCODE_BIN"
+
+# Create ld.so concatenation
+LDSO_OPENCODE="$PREFIX/tmp/ld.so.opencode"
+create_ldso_concat "$OPENCODE_BIN" "$LDSO_OPENCODE" "OpenCode" || {
+ rm -f "$BUN_WRAPPER"
+ fail_warn "Failed to create OpenCode ld.so concatenation"
+}
+
+# Create wrapper script
+create_proot_wrapper "$PREFIX/bin/opencode" "$LDSO_OPENCODE" "$OPENCODE_BIN" "OpenCode"
+
+# Verify
+echo ""
+echo "Verifying OpenCode..."
+OC_VER=$("$PREFIX/bin/opencode" --version 2>/dev/null) || true
+if [ -n "$OC_VER" ]; then
+ echo -e "${GREEN}[OK]${NC} OpenCode v$OC_VER verified"
+else
+ echo -e "${YELLOW}[WARN]${NC} OpenCode --version check failed (may work in interactive mode)"
+fi
+
+
+# ── Step 4: Create OpenCode config ───────────
+
+echo ""
+echo "Setting up OpenCode configuration..."
+
+OPENCODE_CONFIG_DIR="$HOME/.config/opencode"
+OPENCODE_CONFIG="$OPENCODE_CONFIG_DIR/opencode.json"
+mkdir -p "$OPENCODE_CONFIG_DIR"
+
+if [ ! -f "$OPENCODE_CONFIG" ]; then
+ cat > "$OPENCODE_CONFIG" << 'CONFIG'
+{
+ "$schema": "https://opencode.ai/config.json"
+}
+CONFIG
+ echo -e "${GREEN}[OK]${NC} OpenCode config created"
+else
+ echo -e "${GREEN}[OK]${NC} OpenCode config already exists"
+fi
+
+# ── Cleanup ──────────────────────────────────
+
+rm -f "$BUN_WRAPPER"
+
+echo ""
+echo -e "${GREEN}OpenCode installation complete.${NC}"
+if [ -n "${OC_VER:-}" ]; then
+ echo " OpenCode: v$OC_VER"
+fi
+echo " Run: opencode"
diff --git a/scripts/lib.sh b/scripts/lib.sh
new file mode 100755
index 0000000..e304ca4
--- /dev/null
+++ b/scripts/lib.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# lib.sh — Shared function library for all orchestrators
+# Usage: source "$SCRIPT_DIR/scripts/lib.sh" (from repo)
+# source "$PROJECT_DIR/scripts/lib.sh" (from installed copy)
+
+# ── Color constants ──
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BOLD='\033[1m'
+NC='\033[0m'
+
+# ── Project constants ──
+PROJECT_DIR="$HOME/.openclaw-android"
+PLATFORM_MARKER="$PROJECT_DIR/.platform"
+REPO_BASE="https://raw.githubusercontent.com/AidanPark/openclaw-android/main"
+
+BASHRC_MARKER_START="# >>> OpenClaw on Android >>>"
+BASHRC_MARKER_END="# <<< OpenClaw on Android <<<"
+OA_VERSION="1.0.6"
+
+# ── Platform detection ──
+# 1. Explicit marker file (new install and after first update)
+# 2. Legacy detection (v1.0.2 and below, one-time)
+# 3. Detection failure
+detect_platform() {
+ if [ -f "$PLATFORM_MARKER" ]; then
+ cat "$PLATFORM_MARKER"
+ return 0
+ fi
+ if command -v openclaw &>/dev/null; then
+ echo "openclaw"
+ mkdir -p "$(dirname "$PLATFORM_MARKER")"
+ echo "openclaw" > "$PLATFORM_MARKER"
+ return 0
+ fi
+ echo ""
+ return 1
+}
+
+# ── Platform name validation ──
+validate_platform_name() {
+ local name="$1"
+ if [ -z "$name" ]; then
+ echo -e "${RED}[FAIL]${NC} Platform name is empty"
+ return 1
+ fi
+ # Only lowercase alphanumeric + hyphens/underscores allowed
+ if [[ ! "$name" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
+ echo -e "${RED}[FAIL]${NC} Invalid platform name: $name"
+ return 1
+ fi
+ return 0
+}
+
+# ── User confirmation prompt ──
+# Reads from /dev/tty so it works even in curl|bash mode.
+# Termux always has /dev/tty — no fallback for tty-less environments.
+ask_yn() {
+ local prompt="$1"
+ local reply
+ read -rp "$prompt [Y/n] " reply < /dev/tty
+ [[ "${reply:-}" =~ ^[Nn]$ ]] && return 1
+ return 0
+}
+
+# ── Load platform config.env ──
+# $1: platform name, $2: base directory (parent of platforms/)
+load_platform_config() {
+ local platform="$1"
+ local base_dir="$2"
+ local config_path="$base_dir/platforms/$platform/config.env"
+
+ validate_platform_name "$platform" || return 1
+
+ if [ ! -f "$config_path" ]; then
+ echo -e "${RED}[FAIL]${NC} Platform config not found: $config_path"
+ return 1
+ fi
+ source "$config_path"
+ return 0
+}
diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh
index 3fb4aa6..8a37650 100755
--- a/scripts/setup-env.sh
+++ b/scripts/setup-env.sh
@@ -1,78 +1,49 @@
#!/usr/bin/env bash
-# setup-env.sh - Configure environment variables for OpenClaw in Termux
set -euo pipefail
-
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-NC='\033[0m'
-
-echo "=== Setting Up Environment Variables ==="
-echo ""
+source "$(dirname "$0")/lib.sh"
BASHRC="$HOME/.bashrc"
-MARKER_START="# >>> OpenClaw on Android >>>"
-MARKER_END="# <<< OpenClaw on Android <<<"
-
-COMPAT_PATH="$HOME/.openclaw-android/patches/bionic-compat.js"
-
-COMPAT_HEADER="$HOME/.openclaw-android/patches/termux-compat.h"
+PLATFORM=$(detect_platform) || true
-ENV_BLOCK="${MARKER_START}
-export TMPDIR=\"\$PREFIX/tmp\"
+INFRA_VARS="export TMPDIR=\"\$PREFIX/tmp\"
export TMP=\"\$TMPDIR\"
export TEMP=\"\$TMPDIR\"
-export NODE_OPTIONS=\"-r $COMPAT_PATH\"
-export CONTAINER=1
-export CFLAGS=\"-Wno-error=implicit-function-declaration\"
-export CXXFLAGS=\"-include $COMPAT_HEADER\"
-export GYP_DEFINES=\"OS=linux android_ndk_path=\$PREFIX\"
-export CPATH=\"\$PREFIX/include/glib-2.0:\$PREFIX/lib/glib-2.0/include\"
-${MARKER_END}"
+export OA_GLIBC=1"
+
+PATH_LINE="export PATH=\"\$HOME/.local/bin:\$PATH\""
+if [ -n "$PLATFORM" ]; then
+ load_platform_config "$PLATFORM" "$(dirname "$(dirname "$0")")" 2>/dev/null || true
+ if [ "${PLATFORM_NEEDS_NODEJS:-}" = true ]; then
+ PATH_LINE="export PATH=\"\$HOME/.openclaw-android/node/bin:\$HOME/.local/bin:\$PATH\""
+ fi
+fi
-# Create .bashrc if it doesn't exist
-touch "$BASHRC"
+PLATFORM_VARS=""
+PLATFORM_ENV_SCRIPT="$(dirname "$(dirname "$0")")/platforms/$PLATFORM/env.sh"
+if [ -n "$PLATFORM" ] && [ -f "$PLATFORM_ENV_SCRIPT" ]; then
+ PLATFORM_VARS=$(bash "$PLATFORM_ENV_SCRIPT")
+fi
-# Check if block already exists
-if grep -qF "$MARKER_START" "$BASHRC"; then
- echo -e "${YELLOW}[SKIP]${NC} Environment block already exists in $BASHRC"
- echo " Removing old block and re-adding..."
- # Remove old block
- sed -i "/${MARKER_START//\//\\/}/,/${MARKER_END//\//\\/}/d" "$BASHRC"
+ENV_BLOCK="${BASHRC_MARKER_START}
+# platform: ${PLATFORM:-none}
+${PATH_LINE}
+${INFRA_VARS}"
+
+if [ -n "$PLATFORM_VARS" ]; then
+ ENV_BLOCK="${ENV_BLOCK}
+${PLATFORM_VARS}"
fi
-# Append environment block
+ENV_BLOCK="${ENV_BLOCK}
+${BASHRC_MARKER_END}"
+
+touch "$BASHRC"
+if grep -qF "$BASHRC_MARKER_START" "$BASHRC"; then
+ sed -i "/${BASHRC_MARKER_START//\//\\/}/,/${BASHRC_MARKER_END//\//\\/}/d" "$BASHRC"
+fi
echo "" >> "$BASHRC"
echo "$ENV_BLOCK" >> "$BASHRC"
-echo -e "${GREEN}[OK]${NC} Added environment variables to $BASHRC"
-echo ""
-echo "Variables configured:"
-echo " TMPDIR=\$PREFIX/tmp"
-echo " TMP=\$TMPDIR"
-echo " TEMP=\$TMPDIR"
-echo " NODE_OPTIONS=\"-r $COMPAT_PATH\""
-echo " CONTAINER=1 (suppresses systemd checks)"
-echo " CFLAGS=\"-Wno-error=...\" (Clang implicit-function-declaration fix)"
-echo " CXXFLAGS=\"-include ...termux-compat.h\" (native build fixes)"
-echo " GYP_DEFINES=\"OS=linux ...\" (node-gyp Android override)"
-echo " CPATH=\"...glib-2.0...\" (sharp header paths)"
-
-# Source for current session
-export TMPDIR="$PREFIX/tmp"
-export TMP="$TMPDIR"
-export TEMP="$TMPDIR"
-export NODE_OPTIONS="-r $COMPAT_PATH"
-export CONTAINER=1
-export CFLAGS="-Wno-error=implicit-function-declaration"
-export CXXFLAGS="-include $COMPAT_HEADER"
-export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX"
-export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include"
-
-# Create ar symlink if missing (Termux provides llvm-ar but not ar)
if [ ! -e "$PREFIX/bin/ar" ] && [ -x "$PREFIX/bin/llvm-ar" ]; then
ln -s "$PREFIX/bin/llvm-ar" "$PREFIX/bin/ar"
- echo -e "${GREEN}[OK]${NC} Created ar → llvm-ar symlink"
fi
-
-echo ""
-echo -e "${GREEN}Environment setup complete.${NC}"
diff --git a/scripts/setup-paths.sh b/scripts/setup-paths.sh
index a1834a1..86d8b93 100755
--- a/scripts/setup-paths.sh
+++ b/scripts/setup-paths.sh
@@ -1,24 +1,15 @@
#!/usr/bin/env bash
-# setup-paths.sh - Create required directories and symlinks for Termux
set -euo pipefail
-
-GREEN='\033[0;32m'
-NC='\033[0m'
+source "$(dirname "$0")/lib.sh"
echo "=== Setting Up Paths ==="
echo ""
-# Create TMPDIR
-mkdir -p "$PREFIX/tmp/openclaw"
-echo -e "${GREEN}[OK]${NC} Created $PREFIX/tmp/openclaw"
-
-# Create openclaw-android config directory
-mkdir -p "$HOME/.openclaw-android/patches"
-echo -e "${GREEN}[OK]${NC} Created $HOME/.openclaw-android/patches"
+mkdir -p "$PREFIX/tmp"
+echo -e "${GREEN}[OK]${NC} Created $PREFIX/tmp"
-# Create openclaw data directory
-mkdir -p "$HOME/.openclaw"
-echo -e "${GREEN}[OK]${NC} Created $HOME/.openclaw"
+mkdir -p "$PROJECT_DIR/patches"
+echo -e "${GREEN}[OK]${NC} Created $PROJECT_DIR/patches"
echo ""
echo "Standard path mappings (via \$PREFIX):"
diff --git a/sitemap.xml b/sitemap.xml
deleted file mode 100644
index d8d2482..0000000
--- a/sitemap.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
- https://myopenclawhub.com/
- monthly
- 1.0
-
-
diff --git a/tests/verify-install.sh b/tests/verify-install.sh
index 25d6b30..bb30099 100755
--- a/tests/verify-install.sh
+++ b/tests/verify-install.sh
@@ -1,11 +1,8 @@
#!/usr/bin/env bash
-# verify-install.sh - Verify OpenClaw installation on Termux
set -euo pipefail
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-NC='\033[0m'
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/../scripts/lib.sh"
PASS=0
FAIL=0
@@ -29,7 +26,6 @@ check_warn() {
echo "=== OpenClaw on Android - Installation Verification ==="
echo ""
-# 1. Node.js version
if command -v node &>/dev/null; then
NODE_VER=$(node -v)
NODE_MAJOR="${NODE_VER%%.*}"
@@ -43,67 +39,53 @@ else
check_fail "Node.js not found"
fi
-# 2. npm available
if command -v npm &>/dev/null; then
check_pass "npm $(npm -v)"
else
check_fail "npm not found"
fi
-# 3. openclaw command
-if command -v openclaw &>/dev/null; then
- CLAW_VER=$(openclaw --version 2>/dev/null || echo "error")
- if [ "$CLAW_VER" != "error" ]; then
- check_pass "openclaw $CLAW_VER"
- else
- check_warn "openclaw found but --version failed"
- fi
-else
- check_fail "openclaw command not found"
-fi
-
-# 4. Environment variables
if [ -n "${TMPDIR:-}" ]; then
check_pass "TMPDIR=$TMPDIR"
else
check_fail "TMPDIR not set"
fi
-if [ -n "${NODE_OPTIONS:-}" ]; then
- check_pass "NODE_OPTIONS is set"
+if [ "${OA_GLIBC:-}" = "1" ]; then
+ check_pass "OA_GLIBC=1 (glibc architecture)"
else
- check_fail "NODE_OPTIONS not set"
+ check_fail "OA_GLIBC not set"
fi
-if [ "${CONTAINER:-}" = "1" ]; then
- check_pass "CONTAINER=1 (systemd bypass)"
+COMPAT_FILE="$PROJECT_DIR/patches/glibc-compat.js"
+if [ -f "$COMPAT_FILE" ]; then
+ check_pass "glibc-compat.js exists"
else
- check_warn "CONTAINER not set"
+ check_fail "glibc-compat.js not found at $COMPAT_FILE"
fi
-# 5. Patch files
-COMPAT_FILE="$HOME/.openclaw-android/patches/bionic-compat.js"
-if [ -f "$COMPAT_FILE" ]; then
- check_pass "bionic-compat.js exists"
+GLIBC_MARKER="$PROJECT_DIR/.glibc-arch"
+if [ -f "$GLIBC_MARKER" ]; then
+ check_pass "glibc architecture marker (.glibc-arch)"
else
- check_fail "bionic-compat.js not found at $COMPAT_FILE"
+ check_fail "glibc architecture marker not found"
fi
-COMPAT_HEADER="$HOME/.openclaw-android/patches/termux-compat.h"
-if [ -f "$COMPAT_HEADER" ]; then
- check_pass "termux-compat.h exists"
+GLIBC_LDSO="${PREFIX:-}/glibc/lib/ld-linux-aarch64.so.1"
+if [ -f "$GLIBC_LDSO" ]; then
+ check_pass "glibc dynamic linker (ld-linux-aarch64.so.1)"
else
- check_fail "termux-compat.h not found at $COMPAT_HEADER"
+ check_fail "glibc dynamic linker not found at $GLIBC_LDSO"
fi
-if [ -n "${CXXFLAGS:-}" ]; then
- check_pass "CXXFLAGS is set"
+NODE_WRAPPER="$PROJECT_DIR/node/bin/node"
+if [ -f "$NODE_WRAPPER" ] && head -1 "$NODE_WRAPPER" 2>/dev/null | grep -q "bash"; then
+ check_pass "glibc node wrapper script"
else
- check_warn "CXXFLAGS not set (native module builds may fail)"
+ check_fail "glibc node wrapper not found or not a wrapper script"
fi
-# 6. Directories
-for DIR in "$HOME/.openclaw-android" "$HOME/.openclaw" "$PREFIX/tmp"; do
+for DIR in "$PROJECT_DIR" "$PREFIX/tmp"; do
if [ -d "$DIR" ]; then
check_pass "Directory $DIR exists"
else
@@ -111,14 +93,41 @@ for DIR in "$HOME/.openclaw-android" "$HOME/.openclaw" "$PREFIX/tmp"; do
fi
done
-# 7. .bashrc contains env block
+if command -v code-server &>/dev/null; then
+ CS_VER=$(code-server --version 2>/dev/null | head -1 || true)
+ if [ -n "$CS_VER" ]; then
+ check_pass "code-server $CS_VER"
+ else
+ check_warn "code-server found but --version failed"
+ fi
+else
+ check_warn "code-server not installed (non-critical)"
+fi
+
+if command -v opencode &>/dev/null; then
+ check_pass "opencode command available"
+else
+ check_warn "opencode not installed (non-critical)"
+fi
+
if grep -qF "OpenClaw on Android" "$HOME/.bashrc" 2>/dev/null; then
check_pass ".bashrc contains environment block"
else
check_fail ".bashrc missing environment block"
fi
-# Summary
+PLATFORM=$(detect_platform) || true
+PLATFORM_VERIFY="$PROJECT_DIR/platforms/$PLATFORM/verify.sh"
+if [ -n "$PLATFORM" ] && [ -f "$PLATFORM_VERIFY" ]; then
+ if bash "$PLATFORM_VERIFY"; then
+ check_pass "Platform verifier passed ($PLATFORM)"
+ else
+ check_fail "Platform verifier failed ($PLATFORM)"
+ fi
+else
+ check_warn "Platform verifier not found (platform=${PLATFORM:-none})"
+fi
+
echo ""
echo "==============================="
echo -e " Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}, ${YELLOW}$WARN warnings${NC}"
diff --git a/uninstall.sh b/uninstall.sh
old mode 100755
new mode 100644
index 8c69a64..00be220
--- a/uninstall.sh
+++ b/uninstall.sh
@@ -1,11 +1,36 @@
#!/usr/bin/env bash
-# uninstall.sh - Remove OpenClaw on Android from Termux
set -euo pipefail
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BOLD='\033[1m'
-NC='\033[0m'
+PROJECT_DIR="$HOME/.openclaw-android"
+
+if [ -f "$HOME/.openclaw-android/scripts/lib.sh" ]; then
+ # shellcheck source=/dev/null
+ source "$HOME/.openclaw-android/scripts/lib.sh"
+else
+ GREEN='\033[0;32m'
+ YELLOW='\033[1;33m'
+ BOLD='\033[1m'
+ NC='\033[0m'
+ PLATFORM_MARKER="$PROJECT_DIR/.platform"
+ BASHRC_MARKER_START="# >>> OpenClaw on Android >>>"
+ BASHRC_MARKER_END="# <<< OpenClaw on Android <<<"
+
+ ask_yn() {
+ local prompt="$1"
+ local reply
+ read -rp "$prompt [Y/n] " reply < /dev/tty
+ [[ "${reply:-}" =~ ^[Nn]$ ]] && return 1
+ return 0
+ }
+
+ detect_platform() {
+ if [ -f "$PLATFORM_MARKER" ]; then
+ cat "$PLATFORM_MARKER"
+ return 0
+ fi
+ return 1
+ }
+fi
echo ""
echo -e "${BOLD}========================================${NC}"
@@ -13,70 +38,108 @@ echo -e "${BOLD} OpenClaw on Android - Uninstaller${NC}"
echo -e "${BOLD}========================================${NC}"
echo ""
-# Confirm
-read -rp "This will remove OpenClaw and all related config. Continue? [y/N] " REPLY
-if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
+reply=""
+read -rp "This will remove the installation. Continue? [y/N] " reply < /dev/tty
+if [[ ! "$reply" =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
-echo ""
+step() {
+ echo ""
+ echo -e "${BOLD}[$1/7] $2${NC}"
+ echo "----------------------------------------"
+}
-# 1. Uninstall OpenClaw npm package
-echo "Removing OpenClaw npm package..."
-if command -v openclaw &>/dev/null; then
- npm uninstall -g openclaw 2>/dev/null || true
- echo -e "${GREEN}[OK]${NC} openclaw package removed"
+step 1 "Platform uninstall"
+PLATFORM=$(detect_platform 2>/dev/null || true)
+if [ -z "$PLATFORM" ]; then
+ echo -e "${YELLOW}[SKIP]${NC} Platform not detected"
else
- echo -e "${YELLOW}[SKIP]${NC} openclaw not installed"
+ PLATFORM_UNINSTALL="$PROJECT_DIR/platforms/$PLATFORM/uninstall.sh"
+ if [ -f "$PLATFORM_UNINSTALL" ]; then
+ bash "$PLATFORM_UNINSTALL"
+ else
+ echo -e "${YELLOW}[SKIP]${NC} Platform uninstall script not found: $PLATFORM_UNINSTALL"
+ fi
+fi
+
+step 2 "code-server"
+if pgrep -f "code-server" &>/dev/null; then
+ pkill -f "code-server" || true
+ echo -e "${GREEN}[OK]${NC} Stopped running code-server"
fi
-# 2. Remove oaupdate command
-if [ -f "$PREFIX/bin/oaupdate" ]; then
- rm -f "$PREFIX/bin/oaupdate"
- echo -e "${GREEN}[OK]${NC} Removed $PREFIX/bin/oaupdate"
+if ls "$HOME/.local/lib"/code-server-* &>/dev/null 2>&1; then
+ rm -rf "$HOME/.local/lib"/code-server-*
+ echo -e "${GREEN}[OK]${NC} Removed code-server from ~/.local/lib"
else
- echo -e "${YELLOW}[SKIP]${NC} $PREFIX/bin/oaupdate not found"
+ echo -e "${YELLOW}[SKIP]${NC} code-server not found in ~/.local/lib"
fi
-# 3. Remove openclaw-android directory
-if [ -d "$HOME/.openclaw-android" ]; then
- rm -rf "$HOME/.openclaw-android"
- echo -e "${GREEN}[OK]${NC} Removed $HOME/.openclaw-android"
+if [ -f "$HOME/.local/bin/code-server" ] || [ -L "$HOME/.local/bin/code-server" ]; then
+ rm -f "$HOME/.local/bin/code-server"
+ echo -e "${GREEN}[OK]${NC} Removed ~/.local/bin/code-server"
else
- echo -e "${YELLOW}[SKIP]${NC} $HOME/.openclaw-android not found"
+ echo -e "${YELLOW}[SKIP]${NC} ~/.local/bin/code-server not found"
fi
-# 4. Remove environment block from .bashrc
-BASHRC="$HOME/.bashrc"
-MARKER_START="# >>> OpenClaw on Android >>>"
-MARKER_END="# <<< OpenClaw on Android <<<"
+rmdir "$HOME/.local/bin" 2>/dev/null || true
+rmdir "$HOME/.local/lib" 2>/dev/null || true
+rmdir "$HOME/.local" 2>/dev/null || true
+
+step 3 "Chromium"
+if command -v chromium-browser &>/dev/null || command -v chromium &>/dev/null; then
+ pkg uninstall -y chromium 2>/dev/null || true
+ echo -e "${GREEN}[OK]${NC} Removed Chromium"
+else
+ echo -e "${YELLOW}[SKIP]${NC} Chromium not installed"
+fi
-if [ -f "$BASHRC" ] && grep -qF "$MARKER_START" "$BASHRC"; then
- sed -i "/${MARKER_START//\//\\/}/,/${MARKER_END//\//\\/}/d" "$BASHRC"
- # Collapse consecutive blank lines left behind (preserve intentional single blank lines)
+step 4 "oa and oaupdate commands"
+if [ -f "${PREFIX:-}/bin/oa" ]; then
+ rm -f "${PREFIX:-}/bin/oa"
+ echo -e "${GREEN}[OK]${NC} Removed ${PREFIX:-}/bin/oa"
+else
+ echo -e "${YELLOW}[SKIP]${NC} ${PREFIX:-}/bin/oa not found"
+fi
+
+if [ -f "${PREFIX:-}/bin/oaupdate" ]; then
+ rm -f "${PREFIX:-}/bin/oaupdate"
+ echo -e "${GREEN}[OK]${NC} Removed ${PREFIX:-}/bin/oaupdate"
+else
+ echo -e "${YELLOW}[SKIP]${NC} ${PREFIX:-}/bin/oaupdate not found"
+fi
+
+step 5 "glibc components"
+if command -v pacman &>/dev/null && pacman -Q glibc-runner &>/dev/null; then
+ pacman -R glibc-runner --noconfirm || true
+ echo -e "${GREEN}[OK]${NC} Removed glibc-runner package"
+else
+ echo -e "${YELLOW}[SKIP]${NC} glibc-runner not installed"
+fi
+
+step 6 "shell configuration"
+BASHRC="$HOME/.bashrc"
+if [ -f "$BASHRC" ] && grep -qF "$BASHRC_MARKER_START" "$BASHRC"; then
+ sed -i "/${BASHRC_MARKER_START//\//\\/}/,/${BASHRC_MARKER_END//\//\\/}/d" "$BASHRC"
sed -i '/^$/{ N; /^\n$/d }' "$BASHRC"
echo -e "${GREEN}[OK]${NC} Removed environment block from $BASHRC"
else
echo -e "${YELLOW}[SKIP]${NC} No environment block found in $BASHRC"
fi
-# 5. Clean up temp directory
-if [ -d "$PREFIX/tmp/openclaw" ]; then
- rm -rf "$PREFIX/tmp/openclaw"
- echo -e "${GREEN}[OK]${NC} Removed $PREFIX/tmp/openclaw"
-fi
+step 7 "installation directory"
-# 6. Optionally remove openclaw data
-echo ""
-if [ -d "$HOME/.openclaw" ]; then
- read -rp "Remove OpenClaw data directory ($HOME/.openclaw)? [y/N] " REPLY
- if [[ "$REPLY" =~ ^[Yy]$ ]]; then
- rm -rf "$HOME/.openclaw"
- echo -e "${GREEN}[OK]${NC} Removed $HOME/.openclaw"
+if [ -d "$PROJECT_DIR" ]; then
+ if ask_yn "Remove installation directory (~/.openclaw-android)? Includes Node.js, patches, configs."; then
+ rm -rf "$PROJECT_DIR"
+ echo -e "${GREEN}[OK]${NC} Removed $PROJECT_DIR"
else
- echo -e "${YELLOW}[KEEP]${NC} Keeping $HOME/.openclaw"
+ echo -e "${YELLOW}[KEEP]${NC} Keeping $PROJECT_DIR"
fi
+else
+ echo -e "${YELLOW}[SKIP]${NC} $PROJECT_DIR not found"
fi
echo ""
diff --git a/update-core.sh b/update-core.sh
index e21a928..fb19120 100755
--- a/update-core.sh
+++ b/update-core.sh
@@ -1,6 +1,4 @@
#!/usr/bin/env bash
-# update-core.sh - Lightweight updater for OpenClaw on Android (existing installations)
-# Called by update.sh (thin wrapper) or oaupdate command
set -euo pipefail
RED='\033[0;31m'
@@ -9,230 +7,283 @@ YELLOW='\033[1;33m'
BOLD='\033[1m'
NC='\033[0m'
-REPO_BASE="https://raw.githubusercontent.com/AidanPark/openclaw-android/main"
-OPENCLAW_DIR="$HOME/.openclaw-android"
+PROJECT_DIR="$HOME/.openclaw-android"
+PLATFORM_MARKER="$PROJECT_DIR/.platform"
+OA_VERSION="1.0.6"
echo ""
echo -e "${BOLD}========================================${NC}"
-echo -e "${BOLD} OpenClaw on Android - Updater${NC}"
+echo -e "${BOLD} OpenClaw on Android - Updater v${OA_VERSION}${NC}"
echo -e "${BOLD}========================================${NC}"
echo ""
step() {
echo ""
- echo -e "${BOLD}[$1/6] $2${NC}"
+ echo -e "${BOLD}[$1/5] $2${NC}"
echo "----------------------------------------"
}
-# ─────────────────────────────────────────────
step 1 "Pre-flight Check"
-# Check Termux
if [ -z "${PREFIX:-}" ]; then
echo -e "${RED}[FAIL]${NC} Not running in Termux (\$PREFIX not set)"
exit 1
fi
echo -e "${GREEN}[OK]${NC} Termux detected"
-# Check existing OpenClaw installation
-if ! command -v openclaw &>/dev/null; then
- echo -e "${RED}[FAIL]${NC} openclaw command not found"
- echo " Run the full installer first:"
- echo " curl -sL $REPO_BASE/bootstrap.sh | bash"
+if ! command -v curl &>/dev/null; then
+ echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl"
exit 1
fi
-echo -e "${GREEN}[OK]${NC} openclaw $(openclaw --version 2>/dev/null || echo "")"
-# Migrate from old directory name (.openclaw-lite → .openclaw-android)
OLD_DIR="$HOME/.openclaw-lite"
-if [ -d "$OLD_DIR" ] && [ ! -d "$OPENCLAW_DIR" ]; then
- mv "$OLD_DIR" "$OPENCLAW_DIR"
- echo -e "${GREEN}[OK]${NC} Migrated $OLD_DIR → $OPENCLAW_DIR"
-elif [ -d "$OLD_DIR" ] && [ -d "$OPENCLAW_DIR" ]; then
- # Both exist — merge old into new, then remove old
- cp -rn "$OLD_DIR"/. "$OPENCLAW_DIR"/ 2>/dev/null || true
+if [ -d "$OLD_DIR" ] && [ ! -d "$PROJECT_DIR" ]; then
+ mv "$OLD_DIR" "$PROJECT_DIR"
+ echo -e "${GREEN}[OK]${NC} Migrated $OLD_DIR -> $PROJECT_DIR"
+elif [ -d "$OLD_DIR" ] && [ -d "$PROJECT_DIR" ]; then
+ cp -rn "$OLD_DIR"/. "$PROJECT_DIR"/ 2>/dev/null || true
rm -rf "$OLD_DIR"
- echo -e "${GREEN}[OK]${NC} Merged $OLD_DIR into $OPENCLAW_DIR"
+ echo -e "${GREEN}[OK]${NC} Merged $OLD_DIR into $PROJECT_DIR"
else
- mkdir -p "$OPENCLAW_DIR"
+ mkdir -p "$PROJECT_DIR"
fi
-# Check curl
-if ! command -v curl &>/dev/null; then
- echo -e "${RED}[FAIL]${NC} curl not found. Install it with: pkg install curl"
- exit 1
+if [ -f "$PROJECT_DIR/scripts/lib.sh" ]; then
+ source "$PROJECT_DIR/scripts/lib.sh"
fi
-# ─────────────────────────────────────────────
-step 2 "Installing New Packages"
+# Define REPO_TARBALL after sourcing lib.sh to prevent old installs from overwriting it
+REPO_TARBALL="https://github.com/AidanPark/openclaw-android/archive/refs/heads/main.tar.gz"
+
+if ! declare -f detect_platform &>/dev/null; then
+ detect_platform() {
+ if [ -f "$PLATFORM_MARKER" ]; then
+ cat "$PLATFORM_MARKER"
+ return 0
+ fi
+ if command -v openclaw &>/dev/null; then
+ echo "openclaw"
+ mkdir -p "$(dirname "$PLATFORM_MARKER")"
+ echo "openclaw" > "$PLATFORM_MARKER"
+ return 0
+ fi
+ echo ""
+ return 1
+ }
+fi
-# Install ttyd if not already installed
-if command -v ttyd &>/dev/null; then
- echo -e "${GREEN}[OK]${NC} ttyd already installed ($(ttyd --version 2>/dev/null || echo ""))"
-else
- echo "Installing ttyd..."
- if pkg install -y ttyd; then
- echo -e "${GREEN}[OK]${NC} ttyd installed"
- else
- echo -e "${YELLOW}[WARN]${NC} Failed to install ttyd (non-critical)"
- fi
+PLATFORM=$(detect_platform) || {
+ echo -e "${RED}[FAIL]${NC} No platform detected"
+ exit 1
+}
+if [ -z "$PLATFORM" ]; then
+ echo -e "${RED}[FAIL]${NC} No platform detected"
+ exit 1
fi
+echo -e "${GREEN}[OK]${NC} Platform: $PLATFORM"
-# Install PyYAML if not already installed (required for .skill packaging)
-if python -c "import yaml" 2>/dev/null; then
- echo -e "${GREEN}[OK]${NC} PyYAML already installed"
+IS_GLIBC=false
+if [ -f "$PROJECT_DIR/.glibc-arch" ]; then
+ IS_GLIBC=true
+ echo -e "${GREEN}[OK]${NC} Architecture: glibc"
else
- echo "Installing PyYAML..."
- if pip install pyyaml -q; then
- echo -e "${GREEN}[OK]${NC} PyYAML installed"
- else
- echo -e "${YELLOW}[WARN]${NC} Failed to install PyYAML (non-critical)"
- fi
+ echo -e "${YELLOW}[INFO]${NC} Architecture: Bionic (migration required)"
+fi
+
+SDK_INT=$(getprop ro.build.version.sdk 2>/dev/null || echo "0")
+if [ "$SDK_INT" -ge 31 ] 2>/dev/null; then
+ echo -e "${YELLOW}[INFO]${NC} Android 12+ detected — if background processes get killed (signal 9),"
+ echo " see: https://github.com/AidanPark/openclaw-android/blob/main/docs/disable-phantom-process-killer.md"
fi
-# ─────────────────────────────────────────────
-step 3 "Downloading Latest Scripts"
+step 2 "Download Latest Release (tarball)"
-# Download setup-env.sh (needed for .bashrc update)
-TMPFILE=$(mktemp "$PREFIX/tmp/setup-env.XXXXXX.sh") || {
- echo -e "${RED}[FAIL]${NC} Failed to create temporary file (disk full or $PREFIX/tmp missing?)"
+mkdir -p "$PREFIX/tmp"
+RELEASE_TMP=$(mktemp -d "$PREFIX/tmp/oa-update.XXXXXX") || {
+ echo -e "${RED}[FAIL]${NC} Failed to create temp directory"
exit 1
}
-if curl -sfL "$REPO_BASE/scripts/setup-env.sh" -o "$TMPFILE"; then
- echo -e "${GREEN}[OK]${NC} setup-env.sh downloaded"
+trap 'rm -rf "$RELEASE_TMP"' EXIT
+
+echo "Downloading latest scripts..."
+echo " (This may take a moment depending on network speed)"
+if curl -sfL "$REPO_TARBALL" | tar xz -C "$RELEASE_TMP" --strip-components=1; then
+ echo -e "${GREEN}[OK]${NC} Downloaded latest release"
else
- echo -e "${RED}[FAIL]${NC} Failed to download setup-env.sh"
- rm -f "$TMPFILE"
+ echo -e "${RED}[FAIL]${NC} Failed to download release"
exit 1
fi
-# Download bionic-compat.js (patches may have been updated)
-mkdir -p "$OPENCLAW_DIR/patches"
-if curl -sfL "$REPO_BASE/patches/bionic-compat.js" -o "$OPENCLAW_DIR/patches/bionic-compat.js"; then
- echo -e "${GREEN}[OK]${NC} bionic-compat.js updated"
-else
- echo -e "${YELLOW}[WARN]${NC} Failed to download bionic-compat.js (non-critical)"
-fi
+REQUIRED_FILES=(
+ "scripts/lib.sh"
+ "scripts/setup-env.sh"
+ "platforms/$PLATFORM/config.env"
+ "platforms/$PLATFORM/update.sh"
+)
+for f in "${REQUIRED_FILES[@]}"; do
+ if [ ! -f "$RELEASE_TMP/$f" ]; then
+ echo -e "${RED}[FAIL]${NC} Missing required file: $f"
+ echo " The downloaded release may be corrupted. Try again."
+ exit 1
+ fi
+done
+echo -e "${GREEN}[OK]${NC} All required files verified"
-# Download termux-compat.h (native build compatibility)
-if curl -sfL "$REPO_BASE/patches/termux-compat.h" -o "$OPENCLAW_DIR/patches/termux-compat.h"; then
- echo -e "${GREEN}[OK]${NC} termux-compat.h updated"
-else
- echo -e "${YELLOW}[WARN]${NC} Failed to download termux-compat.h (non-critical)"
-fi
+source "$RELEASE_TMP/scripts/lib.sh"
-# Install spawn.h stub if missing (needed for koffi/native module builds)
-if [ ! -f "$PREFIX/include/spawn.h" ]; then
- if curl -sfL "$REPO_BASE/patches/spawn.h" -o "$PREFIX/include/spawn.h"; then
- echo -e "${GREEN}[OK]${NC} spawn.h stub installed"
- else
- echo -e "${YELLOW}[WARN]${NC} Failed to download spawn.h (non-critical)"
- fi
-else
- echo -e "${GREEN}[OK]${NC} spawn.h already exists"
-fi
+step 3 "Update Core Infrastructure"
-# Install systemctl stub (Termux has no systemd)
-if curl -sfL "$REPO_BASE/patches/systemctl" -o "$PREFIX/bin/systemctl"; then
- chmod +x "$PREFIX/bin/systemctl"
- echo -e "${GREEN}[OK]${NC} systemctl stub updated"
-else
- echo -e "${YELLOW}[WARN]${NC} Failed to update systemctl stub (non-critical)"
-fi
+mkdir -p "$PROJECT_DIR/platforms" "$PROJECT_DIR/scripts" "$PROJECT_DIR/patches"
-# Download update.sh (thin wrapper) and install as oaupdate command
-if curl -sfL "$REPO_BASE/update.sh" -o "$PREFIX/bin/oaupdate"; then
- chmod +x "$PREFIX/bin/oaupdate"
- echo -e "${GREEN}[OK]${NC} oaupdate command updated"
-else
- echo -e "${YELLOW}[WARN]${NC} Failed to update oaupdate (non-critical)"
-fi
+rm -rf "$PROJECT_DIR/platforms/$PLATFORM"
+cp -r "$RELEASE_TMP/platforms/$PLATFORM" "$PROJECT_DIR/platforms/"
+
+cp "$RELEASE_TMP/scripts/lib.sh" "$PROJECT_DIR/scripts/lib.sh"
+cp "$RELEASE_TMP/scripts/setup-env.sh" "$PROJECT_DIR/scripts/setup-env.sh"
+
+cp "$RELEASE_TMP/patches/glibc-compat.js" "$PROJECT_DIR/patches/glibc-compat.js"
+cp "$RELEASE_TMP/patches/argon2-stub.js" "$PROJECT_DIR/patches/argon2-stub.js"
+cp "$RELEASE_TMP/patches/spawn.h" "$PROJECT_DIR/patches/spawn.h"
+cp "$RELEASE_TMP/patches/systemctl" "$PROJECT_DIR/patches/systemctl"
+
+cp "$RELEASE_TMP/oa.sh" "$PREFIX/bin/oa"
+chmod +x "$PREFIX/bin/oa"
-# Download build-sharp.sh
-SHARP_TMPFILE=""
-if SHARP_TMPFILE=$(mktemp "$PREFIX/tmp/build-sharp.XXXXXX.sh" 2>/dev/null); then
- if curl -sfL "$REPO_BASE/scripts/build-sharp.sh" -o "$SHARP_TMPFILE"; then
- echo -e "${GREEN}[OK]${NC} build-sharp.sh downloaded"
+cp "$RELEASE_TMP/update.sh" "$PREFIX/bin/oaupdate"
+chmod +x "$PREFIX/bin/oaupdate"
+
+cp "$RELEASE_TMP/uninstall.sh" "$PROJECT_DIR/uninstall.sh"
+chmod +x "$PROJECT_DIR/uninstall.sh"
+
+if [ "$IS_GLIBC" = false ]; then
+ echo ""
+ echo -e "${BOLD}[MIGRATE] Bionic -> glibc Architecture${NC}"
+ echo "----------------------------------------"
+ if bash "$RELEASE_TMP/scripts/install-glibc.sh" && bash "$RELEASE_TMP/scripts/install-nodejs.sh"; then
+ IS_GLIBC=true
+ echo -e "${GREEN}[OK]${NC} glibc migration complete"
else
- echo -e "${YELLOW}[WARN]${NC} Failed to download build-sharp.sh (non-critical)"
- rm -f "$SHARP_TMPFILE"
- SHARP_TMPFILE=""
+ echo -e "${YELLOW}[WARN]${NC} glibc migration failed (non-critical)"
fi
-else
- echo -e "${YELLOW}[WARN]${NC} Failed to create temporary file for build-sharp.sh (non-critical)"
fi
-# ─────────────────────────────────────────────
-step 4 "Updating Environment Variables"
+# Update Node.js if a newer version is available
+if [ "$IS_GLIBC" = true ]; then
+ bash "$RELEASE_TMP/scripts/install-nodejs.sh" || true
+fi
-# Run setup-env.sh to refresh .bashrc block
-bash "$TMPFILE"
-rm -f "$TMPFILE"
+bash "$RELEASE_TMP/scripts/setup-env.sh"
-# Re-export for current session (setup-env.sh runs as subprocess, exports don't propagate)
+GLIBC_NODE_DIR="$PROJECT_DIR/node"
+if [ "$IS_GLIBC" = true ]; then
+ export PATH="$GLIBC_NODE_DIR/bin:$HOME/.local/bin:$PATH"
+ export OA_GLIBC=1
+fi
export TMPDIR="$PREFIX/tmp"
export TMP="$TMPDIR"
export TEMP="$TMPDIR"
-export NODE_OPTIONS="-r $HOME/.openclaw-android/patches/bionic-compat.js"
-export CONTAINER=1
-export CFLAGS="-Wno-error=implicit-function-declaration"
-export CXXFLAGS="-include $HOME/.openclaw-android/patches/termux-compat.h"
-export GYP_DEFINES="OS=linux android_ndk_path=$PREFIX"
-export CPATH="$PREFIX/include/glib-2.0:$PREFIX/lib/glib-2.0/include"
-
-# ─────────────────────────────────────────────
-step 5 "Updating OpenClaw Package"
-
-# Install build dependencies required for sharp's native compilation.
-# This must happen before npm install so that libvips headers are
-# available when node-gyp compiles sharp as a dependency of openclaw.
-echo "Installing build dependencies..."
-if pkg install -y libvips binutils; then
- echo -e "${GREEN}[OK]${NC} libvips and binutils ready"
-else
- echo -e "${YELLOW}[WARN]${NC} Failed to install build dependencies"
- echo " Image processing (sharp) may not compile correctly"
+# Load platform-specific environment variables for current session
+PLATFORM_ENV_SCRIPT="$RELEASE_TMP/platforms/$PLATFORM/env.sh"
+if [ -f "$PLATFORM_ENV_SCRIPT" ]; then
+ eval "$(bash "$PLATFORM_ENV_SCRIPT")"
fi
-# Create ar symlink if missing (Termux provides llvm-ar but not ar)
-if [ ! -e "$PREFIX/bin/ar" ] && [ -x "$PREFIX/bin/llvm-ar" ]; then
- ln -s "$PREFIX/bin/llvm-ar" "$PREFIX/bin/ar"
- echo -e "${GREEN}[OK]${NC} Created ar → llvm-ar symlink"
+step 4 "Update Platform"
+
+if [ -f "$RELEASE_TMP/platforms/$PLATFORM/update.sh" ]; then
+ bash "$RELEASE_TMP/platforms/$PLATFORM/update.sh"
+else
+ echo -e "${YELLOW}[WARN]${NC} Platform update script not found"
fi
-# CXXFLAGS, GYP_DEFINES, and CPATH were exported in step 4.
-# npm runs as a child process of this script and inherits those
-# env vars, so sharp's node-gyp build succeeds here — unlike in
-# 'openclaw update', which spawns npm without these env vars set.
-echo "Updating openclaw npm package..."
-if npm install -g openclaw@latest --no-fund --no-audit; then
- echo -e "${GREEN}[OK]${NC} openclaw package updated"
+step 5 "Update Optional Tools"
+
+if command -v code-server &>/dev/null; then
+ if bash "$RELEASE_TMP/scripts/install-code-server.sh" update; then
+ echo -e "${GREEN}[OK]${NC} code-server update step complete"
+ else
+ echo -e "${YELLOW}[WARN]${NC} code-server update failed (non-critical)"
+ fi
else
- echo -e "${YELLOW}[WARN]${NC} Package update failed (non-critical)"
- echo " Retry manually: npm install -g openclaw@latest"
+ echo -e "${YELLOW}[SKIP]${NC} code-server not installed"
fi
-# ─────────────────────────────────────────────
-step 6 "Building sharp (image processing)"
+if command -v chromium-browser &>/dev/null || command -v chromium &>/dev/null; then
+ if [ -f "$RELEASE_TMP/scripts/install-chromium.sh" ]; then
+ bash "$RELEASE_TMP/scripts/install-chromium.sh" update || true
+ fi
+else
+ echo -e "${YELLOW}[SKIP]${NC} Chromium not installed"
+fi
-if [ -n "$SHARP_TMPFILE" ]; then
- bash "$SHARP_TMPFILE"
- rm -f "$SHARP_TMPFILE"
+if [ "$IS_GLIBC" = false ]; then
+ echo -e "${YELLOW}[SKIP]${NC} OpenCode requires glibc architecture"
else
- echo -e "${YELLOW}[SKIP]${NC} build-sharp.sh was not downloaded"
+ OPENCODE_INSTALLED=false
+ command -v opencode &>/dev/null && OPENCODE_INSTALLED=true
+
+ if [ "$OPENCODE_INSTALLED" = true ]; then
+ CURRENT_OC_VER=$(opencode --version 2>/dev/null || echo "")
+ LATEST_OC_VER=$(npm view opencode-ai version 2>/dev/null || echo "")
+
+ if [ -n "$CURRENT_OC_VER" ] && [ -n "$LATEST_OC_VER" ] && [ "$CURRENT_OC_VER" = "$LATEST_OC_VER" ]; then
+ echo -e "${GREEN}[OK]${NC} OpenCode $CURRENT_OC_VER is already the latest"
+ else
+ if [ -n "$CURRENT_OC_VER" ] && [ -n "$LATEST_OC_VER" ] && [ "$CURRENT_OC_VER" != "$LATEST_OC_VER" ]; then
+ echo "OpenCode update available: $CURRENT_OC_VER -> $LATEST_OC_VER"
+ fi
+ echo " (This may take a few minutes for package download and binary processing)"
+ if bash "$RELEASE_TMP/scripts/install-opencode.sh"; then
+ echo -e "${GREEN}[OK]${NC} OpenCode ${LATEST_OC_VER:-} updated"
+ else
+ echo -e "${YELLOW}[WARN]${NC} OpenCode update failed (non-critical)"
+ fi
+ fi
+ else
+ echo -e "${YELLOW}[SKIP]${NC} OpenCode not installed"
+ fi
fi
-echo ""
-echo -e "${BOLD}========================================${NC}"
-echo -e "${GREEN}${BOLD} Update Complete!${NC}"
-echo -e "${BOLD}========================================${NC}"
-echo ""
+update_ai_tool() {
+ local cmd="$1"
+ local pkg="$2"
+ local label="$3"
+
+ if ! command -v "$cmd" &>/dev/null; then
+ return 1
+ fi
-# Show OpenClaw update status
-openclaw update status 2>/dev/null || true
+ local current_ver latest_ver
+ current_ver=$(npm list -g "$pkg" 2>/dev/null | grep "${pkg##*/}@" | sed 's/.*@//' | tr -d '[:space:]')
+ latest_ver=$(npm view "$pkg" version 2>/dev/null || echo "")
+
+ if [ -n "$current_ver" ] && [ -n "$latest_ver" ] && [ "$current_ver" = "$latest_ver" ]; then
+ echo -e "${GREEN}[OK]${NC} $label $current_ver is already the latest"
+ elif [ -n "$latest_ver" ]; then
+ echo "Updating $label... ($current_ver -> $latest_ver)"
+ echo " (This may take a few minutes depending on network speed)"
+ if npm install -g "$pkg@latest" --no-fund --no-audit --ignore-scripts; then
+ echo -e "${GREEN}[OK]${NC} $label $latest_ver updated"
+ else
+ echo -e "${YELLOW}[WARN]${NC} $label update failed (non-critical)"
+ fi
+ else
+ echo -e "${YELLOW}[WARN]${NC} Could not check $label latest version"
+ fi
+ return 0
+}
+
+AI_FOUND=false
+update_ai_tool "claude" "@anthropic-ai/claude-code" "Claude Code" && AI_FOUND=true
+update_ai_tool "gemini" "@google/gemini-cli" "Gemini CLI" && AI_FOUND=true
+update_ai_tool "codex" "@openai/codex" "Codex CLI" && AI_FOUND=true
+if [ "$AI_FOUND" = false ]; then
+ echo -e "${YELLOW}[SKIP]${NC} No AI CLI tools installed"
+fi
+echo ""
+echo -e "${GREEN}${BOLD} Update Complete!${NC}"
echo ""
echo -e "${YELLOW}Run this to apply changes to the current session:${NC}"
echo ""
echo " source ~/.bashrc"
-echo ""