ssh-workbench/lib-terminal-view/src/main/java/com/roundingmobile/terminalview/TerminalRenderer.kt
jima 7b68e6404b Audit 2026-04-12: C++ hardening, crash fix, dead code, quality
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>
2026-04-12 15:09:05 +02:00

320 lines
13 KiB
Kotlin

package com.roundingmobile.terminalview
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import com.roundingmobile.terminalview.engine.ScreenBuffer
import com.roundingmobile.terminalview.engine.TextAttr
/**
* All terminal Canvas drawing logic. No Android View dependencies — just Canvas and Paint.
* Takes a ScreenBuffer + colors + font metrics, draws characters, colors, cursor,
* selection highlight, and scroll indicator.
*/
class TerminalRenderer {
// --- Font metrics (set by updateFontMetrics, read by render thread) ---
@Volatile var charWidth: Float = 0f; private set
@Volatile var charHeight: Float = 0f; private set
@Volatile var charBaseline: Float = 0f; private set
// --- Colors (set by the view / theme) ---
var fgColor: Int = Color.rgb(0x55, 0xEF, 0xC4)
var bgColor: Int = Color.rgb(0x1A, 0x1A, 0x2E)
var cursorColor: Int = Color.argb(200, 0x55, 0xEF, 0xC4)
/** 16-color ANSI palette — indices 0..15. Updated when theme changes. */
var colorPalette = intArrayOf(
Color.rgb(0x1A, 0x1A, 0x2E), // 0 black
Color.rgb(0xFF, 0x55, 0x55), // 1 red
Color.rgb(0x55, 0xEF, 0xC4), // 2 green
Color.rgb(0xFF, 0xFF, 0x55), // 3 yellow
Color.rgb(0x55, 0x55, 0xFF), // 4 blue
Color.rgb(0xFF, 0x55, 0xFF), // 5 magenta
Color.rgb(0x55, 0xFF, 0xFF), // 6 cyan
Color.rgb(0xDD, 0xDD, 0xDD), // 7 white
Color.rgb(0x55, 0x55, 0x55), // 8 bright black
Color.rgb(0xFF, 0x88, 0x88), // 9 bright red
Color.rgb(0x88, 0xFF, 0xDD), // 10 bright green
Color.rgb(0xFF, 0xFF, 0x88), // 11 bright yellow
Color.rgb(0x88, 0x88, 0xFF), // 12 bright blue
Color.rgb(0xFF, 0x88, 0xFF), // 13 bright magenta
Color.rgb(0x88, 0xFF, 0xFF), // 14 bright cyan
Color.rgb(0xFF, 0xFF, 0xFF), // 15 bright white
)
// xterm 256-color palette: first 16 are overwritten from colorPalette, rest are fixed
private val palette256 = IntArray(256)
init {
rebuildPalette256()
}
/** Rebuild the 256-color palette. Call after changing colorPalette. */
fun rebuildPalette256() {
// Copy first 16 from the theme's ANSI palette
for (i in 0..15) palette256[i] = colorPalette[i]
// 216 color cube (indices 16..231)
for (r in 0..5) for (g in 0..5) for (b in 0..5) {
val idx = 16 + r * 36 + g * 6 + b
palette256[idx] = Color.rgb(
if (r == 0) 0 else 55 + r * 40,
if (g == 0) 0 else 55 + g * 40,
if (b == 0) 0 else 55 + b * 40
)
}
// 24 grayscale (indices 232..255)
for (i in 0..23) {
val v = 8 + i * 10
palette256[232 + i] = Color.rgb(v, v, v)
}
}
// --- Typeface ---
private var terminalTypeface: Typeface = Typeface.MONOSPACE
// --- Paints ---
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = terminalTypeface
}
private val bgPaint = Paint()
private val cursorPaint = Paint()
private val indicatorPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = terminalTypeface
}
private val indicatorBgPaint = Paint()
private val selectionPaint = Paint().apply {
color = Color.argb(80, 0x55, 0xEF, 0xC4)
}
private val handlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(0x55, 0xEF, 0xC4)
}
private val handleStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(0x55, 0xEF, 0xC4)
style = Paint.Style.STROKE
strokeWidth = 3f
}
private var drawCount = 0L
/** Set the terminal typeface. Call before updateFontMetrics. */
fun setTypeface(typeface: Typeface) {
terminalTypeface = typeface
textPaint.typeface = typeface
indicatorPaint.typeface = typeface
}
/** Update font metrics for the given font size in pixels. Call whenever font size or display density changes. */
fun updateFontMetrics(fontSizePx: Float) {
textPaint.textSize = fontSizePx
textPaint.typeface = terminalTypeface
val measuredWidth = textPaint.measureText("M")
charWidth = if (measuredWidth > 0f) measuredWidth else 1f
val fm = textPaint.fontMetrics
val measuredHeight = fm.descent - fm.ascent + fm.leading
charHeight = if (measuredHeight > 0f) measuredHeight else 1f
charBaseline = -fm.ascent
}
/**
* Render the terminal to the given Canvas.
*
* @param canvas target canvas
* @param screen the ScreenBuffer to render
* @param scrollXCols horizontal scroll offset in columns (word-wrap off)
* @param pinchScale current pinch-to-zoom scale factor (1.0 = no zoom)
* @param isPinching whether a pinch gesture is active
* @param selection optional text selection to highlight
* @param density display density for indicator sizing
* @param scaledDensity scaled density for text sizing
* @param logger optional log function
*/
fun render(
canvas: Canvas,
screen: ScreenBuffer?,
scrollXCols: Int = 0,
pinchScale: Float = 1.0f,
isPinching: Boolean = false,
selection: TerminalTextSelection? = null,
urlMatches: List<TerminalUrlDetector.UrlMatch>? = null,
density: Float = 1f,
scaledDensity: Float = 1f,
offsetX: Float = 0f,
offsetY: Float = 0f,
cursorBlinkOn: Boolean = true,
logger: ((String) -> Unit)? = null,
) {
drawCount++
// DECSCNM (reverse screen mode): swap default fg/bg screen-wide
val reverseScreen = screen?.reverseScreenMode == true
val effectiveBg = if (reverseScreen) fgColor else bgColor
val effectiveFg = if (reverseScreen) bgColor else fgColor
// Cursor: in reverse screen mode, use bgColor (which is now the text color)
// so cursor remains visible against the swapped background
cursorPaint.color = if (reverseScreen) {
Color.argb(200, Color.red(bgColor), Color.green(bgColor), Color.blue(bgColor))
} else {
cursorColor
}
canvas.drawColor(effectiveBg)
if (screen == null) return
val rows = screen.rows
val cols = screen.cols
val isScrolled = screen.isScrolledBack
// Offset grid rendering by padding (system insets, disconnected bar, etc.)
if (offsetX != 0f || offsetY != 0f) {
canvas.save()
canvas.translate(offsetX, offsetY)
}
// During pinch: scale the canvas visually, don't change the grid
if (isPinching && pinchScale != 1.0f) {
canvas.save()
canvas.scale(pinchScale, pinchScale)
}
for (row in 0 until rows) {
val y = row * charHeight
for (col in 0 until cols) {
val x = col * charWidth
val bufCol = col + scrollXCols
val attr = screen.getVisibleAttr(row, bufCol)
val ch = screen.getVisibleChar(row, bufCol)
var fg = resolveFg(attr, effectiveFg)
var bg = resolveBg(attr, effectiveBg)
// XOR per-cell reverse with screen-wide reverse (DECSCNM)
if (attr.isReversed != reverseScreen) { val t = fg; fg = bg; bg = t }
// Selection highlight (using absolute position)
if (selection != null && selection.isActive && selection.containsViewport(row, bufCol, screen)) {
bg = selectionPaint.color
}
if (bg != effectiveBg) {
bgPaint.color = bg
canvas.drawRect(x, y, x + charWidth, y + charHeight, bgPaint)
}
// Check if this cell is part of a URL
val isUrl = urlMatches?.any { it.row == row && bufCol in it.colStart..it.colEnd } == true
if (ch != ' ' && ch != '\u0000') {
textPaint.isFakeBoldText = attr.isBold
textPaint.textSkewX = if (attr.isItalic) -0.25f else 0f
if (attr.isHidden) {
textPaint.color = bg
} else {
textPaint.color = fg
if (attr.isBold && attr.fgMode == TextAttr.COLOR_MODE_16 && attr.fg < 8) {
textPaint.color = colorPalette[(attr.fg + 8).coerceAtMost(15)]
}
}
canvas.drawText(ch.toString(), x, y + charBaseline, textPaint)
if (attr.isUnderlined || isUrl) {
canvas.drawLine(x, y + charHeight - 2, x + charWidth, y + charHeight - 2, textPaint)
}
} else if (isUrl) {
// Underline even whitespace within a URL (rare but possible)
textPaint.color = fg
canvas.drawLine(x, y + charHeight - 2, x + charWidth, y + charHeight - 2, textPaint)
}
}
}
// Cursor — only at live view (not scrolled back), respects blink state
if (!isScrolled && screen.cursorVisible && cursorBlinkOn) {
val cursorVisibleCol = screen.cursorCol - scrollXCols
if (cursorVisibleCol in 0 until cols) {
val cx = cursorVisibleCol * charWidth
val cy = screen.cursorRow * charHeight
canvas.drawRect(cx, cy, cx + charWidth, cy + charHeight, cursorPaint)
}
}
// Selection handles — draw teardrop handles at start/end of selection
if (selection != null && selection.isActive) {
val handleRadius = selection.handleRadiusPx
handlePaint.color = cursorColor
handleStrokePaint.color = cursorColor
// Start handle (only if visible in current viewport)
val startPos = selection.getStartHandlePos(charWidth, charHeight, scrollXCols, screen)
if (startPos != null) {
val (sx, sy) = startPos
canvas.drawLine(sx, sy - charHeight, sx, sy, handleStrokePaint)
canvas.drawCircle(sx, sy + handleRadius, handleRadius, handlePaint)
}
// End handle (only if visible in current viewport)
val endPos = selection.getEndHandlePos(charWidth, charHeight, scrollXCols, screen)
if (endPos != null) {
val (ex, ey) = endPos
canvas.drawLine(ex, ey - charHeight, ex, ey, handleStrokePaint)
canvas.drawCircle(ex, ey + handleRadius, handleRadius, handlePaint)
}
}
// Restore canvas if we applied pinch scale
if (isPinching && pinchScale != 1.0f) {
canvas.restore()
}
// Restore canvas if we applied padding offset
if (offsetX != 0f || offsetY != 0f) {
canvas.restore()
}
// Scroll indicator when viewing history
if (isScrolled) {
drawScrollIndicator(canvas, screen.scrollOffset, screen.scrollbackLineCount, density, scaledDensity)
}
if (drawCount <= 10 || drawCount % 100 == 0L) {
logger?.invoke("draw #$drawCount: grid=${cols}x${rows} cursor=(${screen.cursorRow},${screen.cursorCol}) cw=$charWidth ch=$charHeight canvas=${canvas.width}x${canvas.height} scrollback=${screen.scrollbackLineCount} offset=${screen.scrollOffset}")
}
}
private fun drawScrollIndicator(canvas: Canvas, offset: Int, total: Int, density: Float, scaledDensity: Float) {
val text = "[$offset/$total]"
indicatorPaint.color = Color.argb(180, 0xFF, 0xFF, 0xFF)
indicatorPaint.textSize = 12f * scaledDensity
indicatorBgPaint.color = Color.argb(160, 0x00, 0x00, 0x00)
val textWidth = indicatorPaint.measureText(text)
val pad = 8 * density
val x = canvas.width - textWidth - pad * 2
val y = pad
val rect = RectF(x, y, x + textWidth + pad * 2, y + indicatorPaint.textSize + pad * 2)
canvas.drawRoundRect(rect, 4 * density, 4 * density, indicatorBgPaint)
canvas.drawText(text, x + pad, y + pad + indicatorPaint.textSize * 0.85f, indicatorPaint)
}
fun resolveFg(attr: TextAttr, defaultFg: Int = fgColor): Int {
return when (attr.fgMode) {
TextAttr.COLOR_MODE_256 -> palette256[attr.fg.coerceIn(0, 255)]
TextAttr.COLOR_MODE_RGB -> Color.rgb(attr.fgR, attr.fgG, attr.fgB)
else -> {
val idx = attr.fg.coerceIn(0, 15)
if (idx == 7) defaultFg else colorPalette[idx]
}
}
}
fun resolveBg(attr: TextAttr, defaultBg: Int = bgColor): Int {
return when (attr.bgMode) {
TextAttr.COLOR_MODE_256 -> palette256[attr.bg.coerceIn(0, 255)]
TextAttr.COLOR_MODE_RGB -> Color.rgb(attr.bgR, attr.bgG, attr.bgB)
else -> {
val idx = attr.bg.coerceIn(0, 15)
if (idx == 0) defaultBg else colorPalette[idx]
}
}
}
}