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>
320 lines
13 KiB
Kotlin
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]
|
|
}
|
|
}
|
|
}
|
|
}
|