Fifth full codebase audit across all five modules (lib-ssh, lib-terminal-view,
lib-terminal-keyboard, lib-vault-crypto, app).
Security:
- Added cppFlags to lib-vault-crypto build — vault_crypto.cpp JNI bridge was
missing all compiler hardening flags (-fstack-protector-strong, -D_FORTIFY_SOURCE=2)
Bugs fixed:
- SessionNotifier crash: first{} → firstOrNull to prevent NoSuchElementException
- Keyboard modifiers not consumed on SwitchPage/ToggleNumBlock — armed CTRL/ALT
would persist and incorrectly modify the next key press
- KeyManagerViewModel silent exception swallow — now logs errors via FileLogger
- TelnetSession.sendTerminalType() variable shadowing fix
Dead code removed:
- Vt100Parser empty class (Vt220Parser now extends BaseTermParser directly)
- XtermParser.sendPrimaryDA() redundant override (identical to parent)
- TerminalKeyboard dead fields: menuPopupActive, menuPopupItems, miniContainer
- SpecialAction.SETTINGS_OPENED never emitted
- Deprecated 3-arg saveHostKeyFingerprint overload (no callers)
Code quality:
- Color(0xFF6E7979) → AppColors.Muted in ConnectionListScreen
- Hardcoded "v1.0.0" → BuildConfig.VERSION_NAME in SettingsScreen
- SubscriptionScreen back button contentDescription for accessibility
- TAG → companion const val in StartupCommandRunner, PortForwardManager, SftpSessionManager
- TerminalRenderer swapped KDoc comments fixed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
20 KiB
Terminal Parser — Architecture & Implementation Guide
Last updated: 2026-04-01 Module:
lib-terminal-viewPackage:com.roundingmobile.terminalview.engine
Overview
Our terminal emulator implements a complete VT100/VT220/xterm parser, ported from the TellNext C++ ANSICtrl engine. It also supports VT52 compatibility mode. No external terminal library is used — this is fully owned code.
The parser processes raw byte streams from SSH/Telnet/PTY connections and updates a ScreenBuffer model, which is then rendered by the TerminalRenderer.
Parser Hierarchy
BaseTermParser ← C0/C1, CSI dispatch, SGR, SM/RM, DSR (all VT100 features)
└─ Vt220Parser ← DECSCA, DHDW, G2/G3 charsets, locking/single shifts
└─ XtermParser ← OSC (title/color), 256-color/RGB SGR, mouse, bracketed paste
Vt52Parser ← Standalone delegate (NOT in inheritance chain)
There is no intermediate Vt100Parser class — all VT100 features are implemented directly in BaseTermParser. Vt220Parser extends BaseTermParser directly.
Vt52Parser is a separate class used as a delegate by BaseTermParser when VT52 compatibility mode is active. It does not extend BaseTermParser — it has its own independent state machine.
State Machine
ANSI Mode (normal operation)
BaseTermParser uses an enum-based state machine:
enum class State {
GROUND, // Normal text / C0 controls
ESCAPE, // Got ESC (0x1B), waiting for next byte
CSI_PARAMS, // Inside CSI (ESC[), collecting parameters
CSI_GT, // Inside CSI > (ESC[>), DA2 / kitty keyboard
CSI_LT, // Inside CSI < (ESC[<), kitty keyboard pop
OSC_STRING, // Inside OSC (ESC]), collecting string until BEL/ST
DCS_STRING, // Inside DCS (ESCP), collecting until ST
CHARSET // ESC followed by (, ), *, + — waiting for designator
}
Byte flow: process(data) → loop over bytes → dispatch by state.
In GROUND:
0x1B(ESC) → enterESCAPEstate0x00-0x1F→processC0()(BEL, BS, HT, LF, CR, etc.)0x7F(DEL) → ignore0x80-0x9F→processC1()(override in Vt220Parser)0x20+→processPrintable()(batch UTF-8 decode +screen.putChar())
In ESCAPE:
[→ enterCSI_PARAMS]→ enterOSC_STRINGP→ enterDCS_STRING(,),*,+→ enterCHARSET- Other → dispatch via
processEscChar()
In CSI_PARAMS:
- Digits → accumulate in
paramDigitBuf(maxMAX_PARAM_LEN=16chars) ;/,→ flush param, incrementparamCount?,$,!, etc. → setcsiFlags- Final byte (
0x40-0x7E) →dispatchCsi(ch)
VT52 Mode (compatibility)
Activated by CSI ?2l (DECANM reset). When screen.vt52Mode == true, the main process() loop delegates every byte to Vt52Parser.processByte() instead of the ANSI state machine.
VT52 has its own simpler state machine:
enum class State {
GROUND, // Normal text / C0 controls
ESCAPE, // Got ESC, waiting for VT52 command byte
CURSOR_ROW, // ESC Y received, waiting for row byte
CURSOR_COL // Got row, waiting for col byte
}
VT52 mode is exited by ESC < (sets screen.vt52Mode = false).
CSI Parameter Parsing
CSI sequences: ESC [ <params> <final-byte>
params: IntArray(20) // up to 20 numeric parameters
paramMissed: BooleanArray(20) // true if param was not explicitly given
paramCount: Int // number of params collected
paramDigitBuf: StringBuilder // current digit accumulator (max 16 chars)
csiFlags: Int // bitmask: FLAG_QMARK, FLAG_DOLLAR, FLAG_EXCLAM, FLAG_EQUAL, FLAG_QUOTE
MAX_PARAM_LEN = 16
This controls the maximum digits accumulated per CSI parameter. It was increased from 10 to 16 to handle vttest's leading-zero tests, which send parameters like ESC[00000000004;000000001H (11 digits). With 10, the significant digit was silently dropped after the 10 leading zeros.
Quirk: If a CSI parameter exceeds 16 digits, additional digits are silently dropped. The parameter value will be whatever was accumulated from the first 16 digits. In practice, no real terminal application sends more than 5-6 digits.
CSI Flags
Modifier characters before the final byte set flags:
| Flag | Character | Meaning |
|---|---|---|
FLAG_QMARK |
? |
DEC private mode (CSI ? n h/l) |
FLAG_DOLLAR |
$ |
DECRQM, selective erase |
FLAG_EXCLAM |
! |
DECSTR (soft reset) |
FLAG_EQUAL |
= |
Various extended |
FLAG_QUOTE |
" |
DECSCA, DECSASD |
Characters +, *, &, / are recognized as intermediate bytes but do not set flags (no dispatch uses them). < and > switch to dedicated CSI_LT/CSI_GT states instead of setting flags.
Mode Handling (SM/RM)
DEC Private Modes (CSI ? n h/l)
| Mode | Name | Description |
|---|---|---|
| 1 | DECCKM | Application cursor keys (ESC O A vs ESC [ A) |
| 2 | DECANM | ANSI mode. Reset = enter VT52 mode, set = exit VT52 |
| 3 | DECCOLM | 80/132 column mode switching |
| 5 | DECSCNM | Reverse screen (swap fg/bg for entire screen) |
| 6 | DECOM | Origin mode (cursor moves relative to scroll region) |
| 7 | DECAWM | Auto-wrap mode |
| 25 | DECTCEM | Text cursor enable (show/hide cursor) |
| 47 | — | Alternate screen buffer (no save/restore cursor) |
| 67 | DECBKM | Backspace mode |
| 1047 | — | Alternate screen buffer (no save/restore cursor) |
| 1049 | — | Alternate screen buffer (with save/restore cursor) |
| 9 | — | X10 mouse tracking |
| 1000 | — | Normal mouse tracking |
| 1002 | — | Button-event mouse tracking |
| 1003 | — | Any-event mouse tracking |
| 1006 | — | SGR mouse encoding |
| 1015 | — | URXVT mouse encoding |
| 2004 | — | Bracketed paste mode |
ANSI Modes (CSI n h/l)
| Mode | Name | Description |
|---|---|---|
| 2 | KAM | Keyboard lock (no-op, handled externally) |
| 4 | IRM | Insert/replace mode |
| 12 | SRM | Local echo (no-op, handled externally) |
| 20 | LNM | Line feed / new line mode (no-op) |
DECCOLM (Mode 3) — 80/132 Column Switching
DECCOLM is one of the trickiest escape sequences because it triggers a full terminal resize mid-parse.
How it works
- Parser encounters
CSI ?3h(132 cols) orCSI ?3l(80 cols) setDecPrivateMode(3, on)clears the screen, resets scroll region, homes cursor- Calls
listener.onColumnModeChange(cols)which reachesTerminalService TerminalService.replaceEngine()creates a newScreenBufferandXtermParser
The mid-chunk problem (fixed)
Problem: parser.process(bytes) is iterating a byte array. When DECCOLM fires at byte N, replaceEngine() replaces the entry's screenBuffer and parser. But the OLD parser instance continues executing process() for bytes N+1..end, writing to its screen field — which still points to the old (discarded) buffer. All output after the DECCOLM in the same TCP chunk is lost.
Fix: In replaceEngine(), the line entry.parser.screen = newBuffer redirects the still-executing old parser to write to the new buffer. This works because:
BaseTermParser.screenisvar(mutable)- Kotlin's
synchronizedis reentrant (same thread), so the nested lock acquisition inreplaceEngine()→ called fromparser.process()→ underparserLockworks
fun replaceEngine(sessionId: Long, newBuffer: ScreenBuffer, newParser: XtermParser) {
val entry = sessions[sessionId] ?: return
synchronized(entry.parserLock) {
newBuffer.listener = entry.screenBuffer.listener
entry.parser.screen = newBuffer // ← critical: redirect old parser mid-process
entry.screenBuffer = newBuffer
entry.parser = newParser
}
}
Testing DECCOLM
vttest test 1 exercises DECCOLM with these screens:
- Screen 3: 132-column border test (auto-wrap at col 132)
- Screen 4: 80-column border test (auto-wrap at col 80)
- Screen 5: 132-column auto-wrap test
- Screen 6: 80-column auto-wrap test
All require the mid-chunk fix to display the menu after the mode switch.
VT52 Compatibility Mode
Entry and exit
| Direction | Mechanism | Code path |
|---|---|---|
| Enter VT52 | CSI ?2l (DECANM reset) |
setDecPrivateMode(2, false) → screen.vt52Mode = true |
| Exit VT52 | ESC < (from VT52) |
Vt52Parser.processEscape('<') → screen.vt52Mode = false |
| Exit VT52 | CSI ?2h (from ANSI — not reachable from VT52) |
setDecPrivateMode(2, true) → screen.vt52Mode = false |
Important: CSI ?2h cannot exit VT52 mode because CSI sequences (ESC [) don't exist in VT52. The [ after ESC is treated as an unrecognized VT52 command. The ONLY way to exit VT52 from within VT52 is ESC <.
VT52 Escape Sequences
| Sequence | Action | ScreenBuffer call |
|---|---|---|
ESC A |
Cursor up | cursorUp(1) |
ESC B |
Cursor down | cursorDown(1) |
ESC C |
Cursor right | cursorRight(1) |
ESC D |
Cursor left | cursorLeft(1) |
ESC F |
Enter graphics mode | Sets internal graphicsMode flag |
ESC G |
Exit graphics mode | Clears internal graphicsMode flag |
ESC H |
Cursor home | locate(0, 0) |
ESC I |
Reverse linefeed | reverseIndex() |
ESC J |
Erase to end of screen | clearToEndOfScreen() |
ESC K |
Erase to end of line | clearToEndOfLine() |
ESC Y r c |
Direct cursor address | locate(r - 32, c - 32) |
ESC Z |
Identify (DECID) | Responds with ESC / Z |
ESC < |
Exit VT52 mode | screen.vt52Mode = false |
ESC = |
Keypad application mode | (handled by key encoder) |
ESC > |
Keypad numeric mode | (handled by key encoder) |
ESC Y — Direct Cursor Address
This is a 3-byte sequence: ESC Y <row> <col>, where row and col are the target position + 32 (ASCII space = position 0). This requires two extra parser states (CURSOR_ROW, CURSOR_COL).
The row and col bytes can arrive in separate TCP chunks — the state machine correctly handles this case.
DECID Response
In ANSI mode, ESC Z (DECID) sends the primary DA response: ESC [ ? 62 ; 1 ; 2 ; 6 ; 7 ; 8 ; 9 c.
In VT52 mode, ESC Z sends the VT52 identify response: ESC / Z.
C0 Controls in VT52
C0 controls (0x00-0x1F) work identically in both ANSI and VT52 modes:
| Byte | Name | Action |
|---|---|---|
| 0x07 | BEL | listener.onBell() |
| 0x08 | BS | screen.backspace() |
| 0x09 | HT | screen.tab() |
| 0x0A | LF | screen.lineFeed() |
| 0x0B | VT | screen.lineFeed() |
| 0x0C | FF | screen.lineFeed() |
| 0x0D | CR | screen.carriageReturn() |
| 0x18 | CAN | Cancel — return to GROUND |
| 0x1A | SUB | Cancel — return to GROUND |
VT52 Graphics Character Set
VT52 has a graphics mode (ESC F enters, ESC G exits) that maps ASCII bytes 0x5F-0x7E to DEC Special Graphics glyphs (line-drawing characters, math symbols). This is the same character set used by VT100's charset 0 (selected via ESC ( 0).
Mapping Table
| Byte | ASCII | Glyph | Unicode | Description |
|---|---|---|---|---|
| 0x5F | _ |
|
U+0020 | Blank |
| 0x60 | ` |
◆ |
U+25C6 | Diamond |
| 0x61 | a |
▒ |
U+2592 | Checkerboard |
| 0x62 | b |
␉ |
U+2409 | HT symbol |
| 0x63 | c |
␌ |
U+240C | FF symbol |
| 0x64 | d |
␍ |
U+240D | CR symbol |
| 0x65 | e |
␊ |
U+240A | LF symbol |
| 0x66 | f |
° |
U+00B0 | Degree |
| 0x67 | g |
± |
U+00B1 | Plus-minus |
| 0x68 | h |
 |
U+2424 | NL symbol |
| 0x69 | i |
␋ |
U+240B | VT symbol |
| 0x6A | j |
┘ |
U+2518 | Lower-right corner |
| 0x6B | k |
┐ |
U+2510 | Upper-right corner |
| 0x6C | l |
┌ |
U+250C | Upper-left corner |
| 0x6D | m |
└ |
U+2514 | Lower-left corner |
| 0x6E | n |
┼ |
U+253C | Crossing |
| 0x6F | o |
⎺ |
U+23BA | Scan line 1 |
| 0x70 | p |
⎻ |
U+23BB | Scan line 3 |
| 0x71 | q |
─ |
U+2500 | Horizontal line |
| 0x72 | r |
⎼ |
U+23BC | Scan line 7 |
| 0x73 | s |
⎽ |
U+23BD | Scan line 9 |
| 0x74 | t |
├ |
U+251C | Left tee |
| 0x75 | u |
┤ |
U+2524 | Right tee |
| 0x76 | v |
┴ |
U+2534 | Bottom tee |
| 0x77 | w |
┬ |
U+252C | Top tee |
| 0x78 | x |
│ |
U+2502 | Vertical line |
| 0x79 | y |
≤ |
U+2264 | Less-than-or-equal |
| 0x7A | z |
≥ |
U+2265 | Greater-than-or-equal |
| 0x7B | { |
π |
U+03C0 | Pi |
| 0x7C | | |
≠ |
U+2260 | Not-equal |
| 0x7D | } |
£ |
U+00A3 | Pound sign |
| 0x7E | ~ |
· |
U+00B7 | Centered dot |
Characters below 0x5F are NOT mapped — they pass through as normal ASCII.
Implementation
The mapping table lives in Vt52Parser.GRAPHICS_MAP (companion object) as a CharArray. The static function Vt52Parser.mapGraphicsChar(ch) performs the lookup.
When graphicsMode is true, processPrintable() calls mapGraphicsChar() on each ASCII byte before passing it to screen.putChar(). UTF-8 multi-byte characters are not affected by graphics mode.
State reset
Graphics mode is cleared when:
ESC Gis received (explicit exit)ESC <exits VT52 mode (graphics mode is part of VT52 state, not screen state)Vt52Parser.reset()is called (on any DECANM mode change)ScreenBuffer.reset()clearsvt52Mode, and the next DECANM entry callsvt52.reset()
Delegation Architecture
Why VT52 is a separate class
VT52 is not part of the VT100→VT220→Xterm evolution. It's a completely different, simpler protocol from 1975 that DEC terminals supported as a compatibility mode. The escape sequences are incompatible:
| Feature | ANSI (VT100+) | VT52 |
|---|---|---|
| Cursor up | ESC [ A |
ESC A |
| Cursor address | ESC [ row ; col H |
ESC Y row col |
| Clear screen | ESC [ 2 J |
ESC J (erase to end only) |
| Parameters | CSI with numeric params | No parameters |
| Colors/SGR | ESC [ 1 ; 31 m |
Not supported |
Putting VT52 in the inheritance chain would add complexity to every parser level. As a delegate:
BaseTermParserholdsprivate val vt52 = Vt52Parser { screen }- The
process()main loop checksscreen.vt52Modeon every byte - When true, delegates to
vt52.processByte()— the ANSI state machine is bypassed entirely - The lambda
{ screen }ensuresVt52Parseralways uses the current screen reference (important forreplaceEngine())
Screen reference synchronization
Vt52Parser accesses the screen via a () -> ScreenBuffer lambda, not a stored reference. This handles the replaceEngine() scenario where BaseTermParser.screen is swapped mid-parse — the lambda always reads the current value.
Device Attribute Responses
Primary DA (CSI c / ESC Z in ANSI mode)
Response: ESC [ ? 62 ; 1 ; 2 ; 6 ; 7 ; 8 ; 9 c
Meaning: VT220 (62), with capabilities:
- 1: 132 columns
- 2: Printer port
- 6: Selective erase
- 7: DRCS (soft character sets)
- 8: UDK (user-defined keys)
- 9: National replacement character sets
Secondary DA (CSI > c)
Response: ESC [ > 41 ; 0 ; 0 c
Meaning: Model 41 (VT420 class), firmware 0, no hardware options.
VT52 DECID (ESC Z in VT52 mode)
Response: ESC / Z
Meaning: "I am a VT52."
Known Quirks and Gotchas
1. Mid-chunk DECCOLM data loss
Already described above in the DECCOLM section. The fix (entry.parser.screen = newBuffer) is critical — without it, any bytes after a DECCOLM switch in the same TCP chunk are silently lost to the discarded buffer.
2. UTF-8 chunk boundary buffering (FIXED 2026-04-01)
Multi-byte UTF-8 sequences can be split across TCP chunks. For example, a 3-byte UTF-8 character (e.g., € = 0xE2 0x82 0xAC) might arrive as [..., 0xE2] in chunk N and [0x82, 0xAC, ...] in chunk N+1. The parser now buffers incomplete lead bytes in utf8Buffer/utf8BufferLen between process() calls. At the start of the next call, completeUtf8() pulls continuation bytes from the new data and emits the completed character before resuming normal parsing.
3. DCS String Terminator (FIXED 2026-04-01)
The DCS parser now correctly handles ESC \ (String Terminator) as a 2-byte sequence, matching the OSC parser's approach. Previously only 1 byte was consumed, leaking the backslash \ to the screen as a visible character.
4. CSI leading zeros
vttest sends parameters like ESC[00000000004;000000001H (11 digits of leading zeros). With MAX_PARAM_LEN < 11, the significant digit is silently dropped and the parameter defaults to 0→1. Fixed by setting MAX_PARAM_LEN = 16.
3. VT52 ESC Y byte values
ESC Y row/col bytes are raw byte values (position + 32), NOT printable digit characters. Row 5 = byte value 37 (not '5'). This is unlike CSI cursor addressing where parameters are decimal digit strings.
4. VT52 mode is NOT screen state (mostly)
screen.vt52Mode lives on ScreenBuffer because it needs to survive across parser state resets and be checked in the process() loop. But graphicsMode lives on Vt52Parser because it's parser state that should reset when entering/exiting VT52 mode.
5. ANSI state machine is paused during VT52
When VT52 mode is active, the ANSI state field (BaseTermParser.state) is frozen at whatever it was when VT52 was entered. When VT52 exits, ANSI parsing resumes from that state. This is fine because:
- DECANM is a CSI command, so
stateisGROUNDafter dispatching it - The VT52→ANSI transition (
ESC <) always leaves the parser clean
6. Chunk size and log visibility
TerminalService only hex-dumps chunks smaller than 128 bytes. Larger chunks (like the vttest menu after a DECCOLM switch) are processed silently. When debugging, use session recordings (/sdcard/Download/SshWorkbench/recordings/) to capture all bytes.
7. Reentrant synchronized
parser.process() runs under parserLock. When DECCOLM fires, onColumnModeChange() calls replaceEngine() which takes the same parserLock. This works because Kotlin/JVM synchronized is reentrant for the same thread. But code in callbacks must NEVER spawn a new thread that also takes parserLock — it would deadlock.
Testing
Unit tests
| File | Count | Coverage |
|---|---|---|
VtParserTest.kt |
30+ | SGR, CSI, OSC, modes, cursor, colors, UTF-8 chunk splitting |
Vt52ParserTest.kt |
37 | Mode entry/exit, cursor, erase, ESC Y, graphics charset, C0, DECID, split chunks |
VttestParityTest.kt |
5+ | DECCOLM, DECALN, vttest cursor movements (verified against Termux) |
ScreenBufferTest.kt |
100+ | Scrollback, reflow, resize, wrap, insert/delete |
vttest verification
All main vttest tests (1-11) pass:
| Test | Name | Status |
|---|---|---|
| 1 | Cursor movements | Pass (all 6 screens, 80+132 cols) |
| 2 | Screen features | Pass (SGR, scroll, wrap, tabs, save/restore) |
| 3 | Character sets | Pass (all 13 sub-tests) |
| 4 | Double-sized characters | Pass (DHDW rendering) |
| 5 | Keyboard | Skipped (requires physical interaction) |
| 6 | Terminal reports | Pass (DA, DSR, CPR, DECREQTPARM) |
| 7 | VT52 mode | Pass (cursor, erase, graphics charset, DECID) |
| 8 | VT102 insert/delete | Pass (all screens) |
| 9 | Known bugs | Pass (all sub-tests) |
| 10 | Reset and self-test | Pass (RIS) |
| 11 | Non-VT100 (VT220/XTERM) | Pass (colors, BCE, VT220 features) |
Running vttest
# Install and launch with auto-connect
adb -s 22160523026079 install -r app/build/outputs/apk/pro/debug/ssh-workbench.v1.0.0-pro-debug.apk
adb -s 22160523026079 shell am start -n com.roundingmobile.sshworkbench.pro/com.roundingmobile.sshworkbench.ui.MainActivity --es profile "SSHTest"
# Send input via broadcast
adb -s 22160523026079 shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es text 'vttest'"
adb -s 22160523026079 shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT --es text $'\r'"
# Take screenshot for verification
adb -s 22160523026079 shell screencap -p /sdcard/Download/screen.png
adb pull /sdcard/Download/screen.png /tmp/screen.png
File Reference
| File | Lines | Purpose |
|---|---|---|
BaseTermParser.kt |
~855 | Main loop, C0, ESC dispatch, CSI params, SGR, SM/RM, DSR, DA |
Vt220Parser.kt |
~180 | C1 controls, DECSCA, DHDW, G2/G3, charset shifts |
XtermParser.kt |
~7 | OSC title/color (inherits everything else) |
Vt52Parser.kt |
~130 | VT52 delegate: escape dispatch, ESC Y, graphics charset |
ScreenBuffer.kt |
~900 | 2D grid, scrollback, modes, cursor, erase, scroll, reflow |
ScreenRow.kt |
~60 | Single row: chars + attrs arrays, wrap flag |
TextAttr.kt |
~200 | 64-bit packed cell attributes |
MouseMode.kt |
~20 | Mouse tracking mode/encoding enums |
TerminalListener.kt |
~15 | Callback interface: bell, title, sendToHost, columnModeChange |